21 Commits

Author SHA1 Message Date
7b1490dec8 Исправлена ошибка MissingGreenlet в use_skip_exile
Убрана проверка assignment.challenge, которая вызывала lazy loading
в асинхронном контексте. Теперь всегда выполняется явный запрос
для получения game_id из Challenge.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-24 10:16:06 +03:00
765da3c37f update tracjer 2026-01-21 23:29:52 +07:00
9f79daf796 Добавлена поддержка обмена играми с типом прохождения (playthrough)
- Обновлены схемы SwapCandidate и SwapRequestChallengeInfo для поддержки прохождений
- get_swap_candidates теперь возвращает и челленджи, и прохождения
- accept_swap_request теперь корректно меняет challenge_id, game_id, is_playthrough и bonus_assignments
- Обновлён UI для отображения прохождений в списке кандидатов и запросах обмена

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:56:34 +03:00
58c390c768 Исправлена ошибка MissingGreenlet при использовании Wild Card
Добавлена загрузка bonus_assignments через selectinload для wild_card,
чтобы избежать lazy loading в асинхронном контексте.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 18:54:46 +03:00
72089d1b47 Исправлены ошибки Wild Card и skip-assignment
- Wild Card: исправлен game.name → game.title
- Wild Card: добавлена поддержка игр типа playthrough
- points.py: добавлена проверка на None для challenge_points
- PlaythroughInfo: поля сделаны Optional (description, points, proof_type)
- organizer_skip_assignment: добавлен фильтр is_event_assignment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 18:10:40 +03:00
9cfe99ff7e Добавлено tracked_time_minutes в ответы API
Время из трекера не отправлялось в API, так как AssignmentResponse
создавался вручную без этого поля. Теперь tracked_time_minutes
передаётся во всех местах создания ответа.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 11:15:20 +03:00
2d8e80f258 Исправлена зависимость миграции exiled_games
Переименована 030 -> 031, зависит от 030_merge_029_heads

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-10 23:06:39 +03:00
f78eacb1a5 Добавлен Skip with Exile, модерация марафонов и выдача предметов
## Skip with Exile (новый расходник)
- Новая модель ExiledGame для хранения изгнанных игр
- Расходник skip_exile: пропуск без штрафа + игра исключается из пула навсегда
- Фильтрация изгнанных игр при выдаче заданий
- UI кнопка в PlayPage для использования skip_exile

## Модерация марафонов (для организаторов)
- Эндпоинты: skip-assignment, exiled-games, restore-exiled-game
- UI в LeaderboardPage: кнопка скипа у каждого участника
- Выбор типа скипа (обычный/с изгнанием) + причина
- Telegram уведомления о модерации

## Админская выдача предметов
- Эндпоинты: admin grant/remove items, get user inventory
- Новая страница AdminGrantItemPage (как магазин)
- Telegram уведомление при получении подарка

## Исправления миграций
- Миграции 029/030 теперь идемпотентны (проверка существования таблиц)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-10 23:02:37 +03:00
cf0df928b1 Fix app 2026-01-10 09:22:28 +07:00
5c452c5c74 Fix migrations 2026-01-10 08:54:23 +07:00
2b6f2888ee Fix site 2026-01-10 08:48:53 +07:00
b6eecc4483 Time tracker app 2026-01-10 08:48:52 +07:00
3256c40841 Add widget preview and combined widget
- Add live preview iframe in widget settings modal
- Create combined widget (all-in-one: leaderboard + current + progress)
- Add widget type tabs for switching preview
- Update documentation with completed tasks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 19:55:48 +03:00
146ed5e489 Add OBS widgets for streamers
- Add widget token authentication system
- Create leaderboard, current assignment, and progress widgets
- Support dark, light, and neon themes
- Add widget settings modal for URL generation
- Fix avatar loading through backend API proxy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-09 19:16:50 +03:00
cd78a99ce7 Remove points limit 2026-01-09 19:02:08 +07:00
76de7ccbdb fix 2026-01-08 10:06:59 +07:00
e63d6c8489 Promocode system 2026-01-08 10:02:15 +07:00
1751c4dd4c rework shop 2026-01-08 08:49:51 +07:00
2874b64481 Bug fixes 2026-01-08 06:51:15 +07:00
4488a13808 Merge branch 'master' into marathon-v2 2026-01-08 05:37:27 +07:00
6a7717a474 Add shop 2026-01-05 08:42:49 +07:00
132 changed files with 24706 additions and 275 deletions

View File

@@ -0,0 +1,230 @@
"""Add shop system with coins, items, inventory, certification
Revision ID: 023_add_shop_system
Revises: 022_add_notification_settings
Create Date: 2025-01-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '023_add_shop_system'
down_revision: Union[str, None] = '022_add_notification_settings'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
# === 1. Создаём таблицу shop_items ===
if not table_exists('shop_items'):
op.create_table(
'shop_items',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('item_type', sa.String(30), nullable=False, index=True),
sa.Column('code', sa.String(50), nullable=False, unique=True, index=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('price', sa.Integer(), nullable=False),
sa.Column('rarity', sa.String(20), nullable=False, server_default='common'),
sa.Column('asset_data', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('available_from', sa.DateTime(), nullable=True),
sa.Column('available_until', sa.DateTime(), nullable=True),
sa.Column('stock_limit', sa.Integer(), nullable=True),
sa.Column('stock_remaining', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# === 2. Создаём таблицу user_inventory ===
if not table_exists('user_inventory'):
op.create_table(
'user_inventory',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('quantity', sa.Integer(), nullable=False, server_default='1'),
sa.Column('equipped', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('purchased_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('expires_at', sa.DateTime(), nullable=True),
)
# === 3. Создаём таблицу coin_transactions ===
if not table_exists('coin_transactions'):
op.create_table(
'coin_transactions',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('amount', sa.Integer(), nullable=False),
sa.Column('transaction_type', sa.String(30), nullable=False),
sa.Column('reference_type', sa.String(30), nullable=True),
sa.Column('reference_id', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# === 4. Создаём таблицу consumable_usages ===
if not table_exists('consumable_usages'):
op.create_table(
'consumable_usages',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('item_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='CASCADE'), nullable=False),
sa.Column('marathon_id', sa.Integer(), sa.ForeignKey('marathons.id', ondelete='CASCADE'), nullable=True),
sa.Column('assignment_id', sa.Integer(), sa.ForeignKey('assignments.id', ondelete='CASCADE'), nullable=True),
sa.Column('used_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('effect_data', sa.JSON(), nullable=True),
)
# === 5. Добавляем поля в users ===
# coins_balance - баланс монет
if not column_exists('users', 'coins_balance'):
op.add_column('users', sa.Column('coins_balance', sa.Integer(), nullable=False, server_default='0'))
# equipped_frame_id - экипированная рамка
if not column_exists('users', 'equipped_frame_id'):
op.add_column('users', sa.Column('equipped_frame_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_title_id - экипированный титул
if not column_exists('users', 'equipped_title_id'):
op.add_column('users', sa.Column('equipped_title_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_name_color_id - экипированный цвет ника
if not column_exists('users', 'equipped_name_color_id'):
op.add_column('users', sa.Column('equipped_name_color_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# equipped_background_id - экипированный фон
if not column_exists('users', 'equipped_background_id'):
op.add_column('users', sa.Column('equipped_background_id', sa.Integer(), sa.ForeignKey('shop_items.id', ondelete='SET NULL'), nullable=True))
# === 6. Добавляем поля сертификации в marathons ===
# certification_status - статус сертификации
if not column_exists('marathons', 'certification_status'):
op.add_column('marathons', sa.Column('certification_status', sa.String(20), nullable=False, server_default='none'))
# certification_requested_at - когда подана заявка
if not column_exists('marathons', 'certification_requested_at'):
op.add_column('marathons', sa.Column('certification_requested_at', sa.DateTime(), nullable=True))
# certified_at - когда сертифицирован
if not column_exists('marathons', 'certified_at'):
op.add_column('marathons', sa.Column('certified_at', sa.DateTime(), nullable=True))
# certified_by_id - кем сертифицирован
if not column_exists('marathons', 'certified_by_id'):
op.add_column('marathons', sa.Column('certified_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True))
# certification_rejection_reason - причина отказа
if not column_exists('marathons', 'certification_rejection_reason'):
op.add_column('marathons', sa.Column('certification_rejection_reason', sa.Text(), nullable=True))
# === 7. Добавляем настройки consumables в marathons ===
# allow_skips - разрешены ли скипы
if not column_exists('marathons', 'allow_skips'):
op.add_column('marathons', sa.Column('allow_skips', sa.Boolean(), nullable=False, server_default='true'))
# max_skips_per_participant - лимит скипов на участника
if not column_exists('marathons', 'max_skips_per_participant'):
op.add_column('marathons', sa.Column('max_skips_per_participant', sa.Integer(), nullable=True))
# allow_consumables - разрешены ли расходуемые
if not column_exists('marathons', 'allow_consumables'):
op.add_column('marathons', sa.Column('allow_consumables', sa.Boolean(), nullable=False, server_default='true'))
# === 8. Добавляем поля в participants ===
# coins_earned - заработано монет в марафоне
if not column_exists('participants', 'coins_earned'):
op.add_column('participants', sa.Column('coins_earned', sa.Integer(), nullable=False, server_default='0'))
# skips_used - использовано скипов
if not column_exists('participants', 'skips_used'):
op.add_column('participants', sa.Column('skips_used', sa.Integer(), nullable=False, server_default='0'))
# active_boost_multiplier - активный множитель буста
if not column_exists('participants', 'active_boost_multiplier'):
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
# active_boost_expires_at - когда истекает буст
if not column_exists('participants', 'active_boost_expires_at'):
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
# has_shield - есть ли активный щит
if not column_exists('participants', 'has_shield'):
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
def downgrade() -> None:
# === Удаляем поля из participants ===
if column_exists('participants', 'has_shield'):
op.drop_column('participants', 'has_shield')
if column_exists('participants', 'active_boost_expires_at'):
op.drop_column('participants', 'active_boost_expires_at')
if column_exists('participants', 'active_boost_multiplier'):
op.drop_column('participants', 'active_boost_multiplier')
if column_exists('participants', 'skips_used'):
op.drop_column('participants', 'skips_used')
if column_exists('participants', 'coins_earned'):
op.drop_column('participants', 'coins_earned')
# === Удаляем поля consumables из marathons ===
if column_exists('marathons', 'allow_consumables'):
op.drop_column('marathons', 'allow_consumables')
if column_exists('marathons', 'max_skips_per_participant'):
op.drop_column('marathons', 'max_skips_per_participant')
if column_exists('marathons', 'allow_skips'):
op.drop_column('marathons', 'allow_skips')
# === Удаляем поля сертификации из marathons ===
if column_exists('marathons', 'certification_rejection_reason'):
op.drop_column('marathons', 'certification_rejection_reason')
if column_exists('marathons', 'certified_by_id'):
op.drop_column('marathons', 'certified_by_id')
if column_exists('marathons', 'certified_at'):
op.drop_column('marathons', 'certified_at')
if column_exists('marathons', 'certification_requested_at'):
op.drop_column('marathons', 'certification_requested_at')
if column_exists('marathons', 'certification_status'):
op.drop_column('marathons', 'certification_status')
# === Удаляем поля из users ===
if column_exists('users', 'equipped_background_id'):
op.drop_column('users', 'equipped_background_id')
if column_exists('users', 'equipped_name_color_id'):
op.drop_column('users', 'equipped_name_color_id')
if column_exists('users', 'equipped_title_id'):
op.drop_column('users', 'equipped_title_id')
if column_exists('users', 'equipped_frame_id'):
op.drop_column('users', 'equipped_frame_id')
if column_exists('users', 'coins_balance'):
op.drop_column('users', 'coins_balance')
# === Удаляем таблицы ===
if table_exists('consumable_usages'):
op.drop_table('consumable_usages')
if table_exists('coin_transactions'):
op.drop_table('coin_transactions')
if table_exists('user_inventory'):
op.drop_table('user_inventory')
if table_exists('shop_items'):
op.drop_table('shop_items')

View File

@@ -0,0 +1,495 @@
"""Seed shop items (frames, titles, consumables)
Revision ID: 024_seed_shop_items
Revises: 023_add_shop_system
Create Date: 2025-01-05
"""
from typing import Sequence, Union
from datetime import datetime
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '024_seed_shop_items'
down_revision: Union[str, None] = '023_add_shop_system'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not table_exists('shop_items'):
return
now = datetime.utcnow()
# Таблица shop_items
shop_items = sa.table(
'shop_items',
sa.column('id', sa.Integer),
sa.column('item_type', sa.String),
sa.column('code', sa.String),
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('price', sa.Integer),
sa.column('rarity', sa.String),
sa.column('asset_data', sa.JSON),
sa.column('is_active', sa.Boolean),
sa.column('created_at', sa.DateTime),
)
# === Рамки аватара ===
frames = [
{
'item_type': 'frame',
'code': 'frame_bronze',
'name': 'Бронзовая рамка',
'description': 'Простая бронзовая рамка для начинающих',
'price': 50,
'rarity': 'common',
'asset_data': {
'border_color': '#CD7F32',
'border_width': 3,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_silver',
'name': 'Серебряная рамка',
'description': 'Элегантная серебряная рамка',
'price': 100,
'rarity': 'uncommon',
'asset_data': {
'border_color': '#C0C0C0',
'border_width': 3,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_gold',
'name': 'Золотая рамка',
'description': 'Престижная золотая рамка',
'price': 200,
'rarity': 'rare',
'asset_data': {
'border_color': '#FFD700',
'border_width': 4,
'border_style': 'solid'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_diamond',
'name': 'Бриллиантовая рамка',
'description': 'Сверкающая бриллиантовая рамка для истинных ценителей',
'price': 500,
'rarity': 'epic',
'asset_data': {
'border_color': '#B9F2FF',
'border_width': 4,
'border_style': 'double',
'glow': True,
'glow_color': '#B9F2FF'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_fire',
'name': 'Огненная рамка',
'description': 'Анимированная рамка с эффектом пламени',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'border_style': 'gradient',
'gradient': ['#FF4500', '#FF8C00', '#FFD700'],
'animated': True,
'animation': 'fire-pulse'
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_neon',
'name': 'Неоновая рамка',
'description': 'Яркая неоновая рамка с свечением',
'price': 800,
'rarity': 'epic',
'asset_data': {
'border_color': '#00FF00',
'border_width': 3,
'glow': True,
'glow_color': '#00FF00',
'glow_intensity': 10
},
'is_active': True,
},
{
'item_type': 'frame',
'code': 'frame_rainbow',
'name': 'Радужная рамка',
'description': 'Переливающаяся радужная рамка',
'price': 1500,
'rarity': 'legendary',
'asset_data': {
'border_style': 'gradient',
'gradient': ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#9400D3'],
'animated': True,
'animation': 'rainbow-rotate'
},
'is_active': True,
},
]
# === Титулы ===
titles = [
{
'item_type': 'title',
'code': 'title_newcomer',
'name': 'Новичок',
'description': 'Первый шаг в мир марафонов',
'price': 30,
'rarity': 'common',
'asset_data': {
'text': 'Новичок',
'color': '#808080'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_runner',
'name': 'Марафонец',
'description': 'Опытный участник марафонов',
'price': 100,
'rarity': 'uncommon',
'asset_data': {
'text': 'Марафонец',
'color': '#4169E1'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_hunter',
'name': 'Охотник за челленджами',
'description': 'Мастер выполнения сложных заданий',
'price': 200,
'rarity': 'rare',
'asset_data': {
'text': 'Охотник за челленджами',
'color': '#228B22'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_veteran',
'name': 'Ветеран',
'description': 'Закаленный в боях участник',
'price': 300,
'rarity': 'rare',
'asset_data': {
'text': 'Ветеран',
'color': '#8B4513'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_champion',
'name': 'Чемпион',
'description': 'Победитель марафонов',
'price': 500,
'rarity': 'epic',
'asset_data': {
'text': 'Чемпион',
'color': '#FFD700',
'icon': 'trophy'
},
'is_active': True,
},
{
'item_type': 'title',
'code': 'title_legend',
'name': 'Легенда',
'description': 'Легендарный участник марафонов',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'text': 'Легенда',
'color': '#FF4500',
'glow': True,
'icon': 'star'
},
'is_active': True,
},
]
# === Цвета никнейма ===
name_colors = [
{
'item_type': 'name_color',
'code': 'color_red',
'name': 'Красный ник',
'description': 'Яркий красный цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#FF4444'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_blue',
'name': 'Синий ник',
'description': 'Глубокий синий цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#4444FF'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_green',
'name': 'Зеленый ник',
'description': 'Сочный зеленый цвет никнейма',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'style': 'solid',
'color': '#44FF44'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_purple',
'name': 'Фиолетовый ник',
'description': 'Королевский фиолетовый цвет',
'price': 200,
'rarity': 'rare',
'asset_data': {
'style': 'solid',
'color': '#9932CC'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gold',
'name': 'Золотой ник',
'description': 'Престижный золотой цвет',
'price': 300,
'rarity': 'rare',
'asset_data': {
'style': 'solid',
'color': '#FFD700'
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gradient_sunset',
'name': 'Закат',
'description': 'Красивый градиент заката',
'price': 500,
'rarity': 'epic',
'asset_data': {
'style': 'gradient',
'gradient': ['#FF6B6B', '#FFE66D']
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_gradient_ocean',
'name': 'Океан',
'description': 'Градиент морских глубин',
'price': 500,
'rarity': 'epic',
'asset_data': {
'style': 'gradient',
'gradient': ['#4ECDC4', '#44A3FF']
},
'is_active': True,
},
{
'item_type': 'name_color',
'code': 'color_rainbow',
'name': 'Радужный ник',
'description': 'Анимированный радужный цвет',
'price': 1000,
'rarity': 'legendary',
'asset_data': {
'style': 'animated',
'animation': 'rainbow-shift'
},
'is_active': True,
},
]
# === Фоны профиля ===
backgrounds = [
{
'item_type': 'background',
'code': 'bg_dark',
'name': 'Тёмный фон',
'description': 'Элегантный тёмный фон',
'price': 100,
'rarity': 'common',
'asset_data': {
'type': 'solid',
'color': '#1a1a2e'
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_gradient_purple',
'name': 'Фиолетовый градиент',
'description': 'Красивый фиолетовый градиент',
'price': 200,
'rarity': 'uncommon',
'asset_data': {
'type': 'gradient',
'gradient': ['#1a1a2e', '#4a0080']
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_stars',
'name': 'Звёздное небо',
'description': 'Фон с мерцающими звёздами',
'price': 400,
'rarity': 'rare',
'asset_data': {
'type': 'pattern',
'pattern': 'stars',
'animated': True
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_gaming',
'name': 'Игровой фон',
'description': 'Фон с игровыми элементами',
'price': 500,
'rarity': 'epic',
'asset_data': {
'type': 'pattern',
'pattern': 'gaming-icons'
},
'is_active': True,
},
{
'item_type': 'background',
'code': 'bg_fire',
'name': 'Огненный фон',
'description': 'Анимированный огненный фон',
'price': 800,
'rarity': 'legendary',
'asset_data': {
'type': 'animated',
'animation': 'fire-particles'
},
'is_active': True,
},
]
# === Расходуемые предметы ===
consumables = [
{
'item_type': 'consumable',
'code': 'skip',
'name': 'Пропуск',
'description': 'Пропустить текущее задание без штрафа и потери streak',
'price': 100,
'rarity': 'common',
'asset_data': {
'effect': 'skip',
'icon': 'skip-forward'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'shield',
'name': 'Щит',
'description': 'Защита от штрафа при следующем дропе. Streak сохраняется.',
'price': 150,
'rarity': 'uncommon',
'asset_data': {
'effect': 'shield',
'icon': 'shield'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'boost',
'name': 'Буст x1.5',
'description': 'Множитель очков x1.5 на текущее задание',
'price': 200,
'rarity': 'rare',
'asset_data': {
'effect': 'boost',
'multiplier': 1.5,
'one_time': True,
'icon': 'zap'
},
'is_active': True,
},
{
'item_type': 'consumable',
'code': 'reroll',
'name': 'Перекрут',
'description': 'Перекрутить колесо и получить новое задание',
'price': 80,
'rarity': 'common',
'asset_data': {
'effect': 'reroll',
'icon': 'refresh-cw'
},
'is_active': True,
},
]
# Вставляем все товары
all_items = frames + titles + name_colors + backgrounds + consumables
# Добавляем created_at ко всем товарам
for item in all_items:
item['created_at'] = now
op.bulk_insert(shop_items, all_items)
def downgrade() -> None:
# Удаляем все seed-товары по коду
op.execute("DELETE FROM shop_items WHERE code LIKE 'frame_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'title_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'color_%'")
op.execute("DELETE FROM shop_items WHERE code LIKE 'bg_%'")
op.execute("DELETE FROM shop_items WHERE code IN ('skip', 'shield', 'boost', 'reroll')")

View File

@@ -0,0 +1,52 @@
"""Simplify boost consumable - make it one-time instead of timed
Revision ID: 025_simplify_boost
Revises: 024_seed_shop_items
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '025_simplify_boost'
down_revision: Union[str, None] = '024_seed_shop_items'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c['name'] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# Add new boolean column for one-time boost
if not column_exists('participants', 'has_active_boost'):
op.add_column('participants', sa.Column('has_active_boost', sa.Boolean(), nullable=False, server_default='false'))
# Remove old timed boost columns
if column_exists('participants', 'active_boost_multiplier'):
op.drop_column('participants', 'active_boost_multiplier')
if column_exists('participants', 'active_boost_expires_at'):
op.drop_column('participants', 'active_boost_expires_at')
def downgrade() -> None:
# Restore old columns
if not column_exists('participants', 'active_boost_multiplier'):
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
if not column_exists('participants', 'active_boost_expires_at'):
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
# Remove new column
if column_exists('participants', 'has_active_boost'):
op.drop_column('participants', 'has_active_boost')

View File

@@ -0,0 +1,46 @@
"""Update boost description to one-time usage
Revision ID: 026_update_boost_desc
Revises: 025_simplify_boost
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '026_update_boost_desc'
down_revision: Union[str, None] = '025_simplify_boost'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Update boost description in shop_items table
op.execute("""
UPDATE shop_items
SET description = 'Множитель очков x1.5 на текущее задание',
asset_data = jsonb_set(
asset_data::jsonb - 'duration_hours',
'{one_time}',
'true'
)
WHERE code = 'boost' AND item_type = 'consumable'
""")
def downgrade() -> None:
# Revert boost description
op.execute("""
UPDATE shop_items
SET description = 'Множитель очков x1.5 на следующие 2 часа',
asset_data = jsonb_set(
asset_data::jsonb - 'one_time',
'{duration_hours}',
'2'
)
WHERE code = 'boost' AND item_type = 'consumable'
""")

View File

@@ -0,0 +1,83 @@
"""Consumables redesign: remove shield/reroll, add wild_card/lucky_dice/copycat/undo
Revision ID: 027_consumables_redesign
Revises: 026_update_boost_desc
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from datetime import datetime
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '027_consumables_redesign'
down_revision: Union[str, None] = '026_update_boost_desc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Remove has_shield column from participants
op.drop_column('participants', 'has_shield')
# 2. Add new columns for lucky_dice and undo
op.add_column('participants', sa.Column('has_lucky_dice', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('participants', sa.Column('lucky_dice_multiplier', sa.Float(), nullable=True))
op.add_column('participants', sa.Column('last_drop_points', sa.Integer(), nullable=True))
op.add_column('participants', sa.Column('last_drop_streak_before', sa.Integer(), nullable=True))
op.add_column('participants', sa.Column('can_undo', sa.Boolean(), nullable=False, server_default='false'))
# 3. Remove old consumables from shop
op.execute("DELETE FROM shop_items WHERE code IN ('reroll', 'shield')")
# 4. Update boost price from 200 to 150
op.execute("UPDATE shop_items SET price = 150 WHERE code = 'boost'")
# 5. Add new consumables to shop
now = datetime.utcnow().isoformat()
op.execute(f"""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
VALUES
('consumable', 'wild_card', 'Дикая карта', 'Выбери игру и получи случайное задание из неё', 150, 'uncommon',
'{{"effect": "wild_card", "icon": "shuffle"}}', true, '{now}'),
('consumable', 'lucky_dice', 'Счастливые кости', 'Случайный множитель очков (1.5x - 4.0x)', 250, 'rare',
'{{"effect": "lucky_dice", "multipliers": [1.5, 2.0, 2.5, 3.0, 3.5, 4.0], "icon": "dice"}}', true, '{now}'),
('consumable', 'copycat', 'Копикэт', 'Скопируй задание любого участника марафона', 300, 'epic',
'{{"effect": "copycat", "icon": "copy"}}', true, '{now}'),
('consumable', 'undo', 'Отмена', 'Отмени последний дроп и верни очки со стриком', 300, 'epic',
'{{"effect": "undo", "icon": "undo"}}', true, '{now}')
""")
def downgrade() -> None:
# 1. Remove new columns
op.drop_column('participants', 'can_undo')
op.drop_column('participants', 'last_drop_streak_before')
op.drop_column('participants', 'last_drop_points')
op.drop_column('participants', 'lucky_dice_multiplier')
op.drop_column('participants', 'has_lucky_dice')
# 2. Add back has_shield
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
# 3. Remove new consumables
op.execute("DELETE FROM shop_items WHERE code IN ('wild_card', 'lucky_dice', 'copycat', 'undo')")
# 4. Restore boost price back to 200
op.execute("UPDATE shop_items SET price = 200 WHERE code = 'boost'")
# 5. Add back old consumables
now = datetime.utcnow().isoformat()
op.execute(f"""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
VALUES
('consumable', 'shield', 'Щит', 'Защита от штрафа при следующем дропе. Streak сохраняется.', 150, 'uncommon',
'{{"effect": "shield", "icon": "shield"}}', true, '{now}'),
('consumable', 'reroll', 'Перекрут', 'Перекрутить колесо и получить новое задание', 80, 'common',
'{{"effect": "reroll", "icon": "refresh-cw"}}', true, '{now}')
""")

View File

@@ -0,0 +1,58 @@
"""Add promo codes system
Revision ID: 028_add_promo_codes
Revises: 027_consumables_redesign
Create Date: 2026-01-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '028_add_promo_codes'
down_revision: Union[str, None] = '027_consumables_redesign'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create promo_codes table
op.create_table(
'promo_codes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(50), nullable=False),
sa.Column('coins_amount', sa.Integer(), nullable=False),
sa.Column('max_uses', sa.Integer(), nullable=True),
sa.Column('uses_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('valid_from', sa.DateTime(), nullable=True),
sa.Column('valid_until', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='CASCADE'),
)
op.create_index('ix_promo_codes_code', 'promo_codes', ['code'], unique=True)
# Create promo_code_redemptions table
op.create_table(
'promo_code_redemptions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('promo_code_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('coins_awarded', sa.Integer(), nullable=False),
sa.Column('redeemed_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['promo_code_id'], ['promo_codes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
)
op.create_index('ix_promo_code_redemptions_user_id', 'promo_code_redemptions', ['user_id'])
def downgrade() -> None:
op.drop_table('promo_code_redemptions')
op.drop_table('promo_codes')

View File

@@ -0,0 +1,30 @@
"""Add tracked_time_minutes to assignments
Revision ID: 029_add_tracked_time
Revises: 028_add_promo_codes
Create Date: 2026-01-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '029_add_tracked_time'
down_revision: Union[str, None] = '028_add_promo_codes'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add tracked_time_minutes column to assignments table
op.add_column(
'assignments',
sa.Column('tracked_time_minutes', sa.Integer(), nullable=False, server_default='0')
)
def downgrade() -> None:
op.drop_column('assignments', 'tracked_time_minutes')

View File

@@ -0,0 +1,46 @@
"""Add widget tokens
Revision ID: 029
Revises: 028
Create Date: 2025-01-09
"""
from alembic import op
from sqlalchemy import inspect
import sqlalchemy as sa
revision = '029_add_widget_tokens'
down_revision = '028_add_promo_codes'
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade():
if table_exists('widget_tokens'):
return
op.create_table(
'widget_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(64), nullable=False),
sa.Column('participant_id', sa.Integer(), nullable=False),
sa.Column('marathon_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['participant_id'], ['participants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['marathon_id'], ['marathons.id'], ondelete='CASCADE'),
)
op.create_index('ix_widget_tokens_token', 'widget_tokens', ['token'], unique=True)
def downgrade():
op.drop_index('ix_widget_tokens_token', table_name='widget_tokens')
op.drop_table('widget_tokens')

View File

@@ -0,0 +1,28 @@
"""Merge 029 heads
Revision ID: 030_merge_029_heads
Revises: 029_add_tracked_time, 029_add_widget_tokens
Create Date: 2026-01-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '030_merge_029_heads'
down_revision: Union[str, Sequence[str]] = ('029_add_tracked_time', '029_add_widget_tokens')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Merge migration - no changes needed
pass
def downgrade() -> None:
# Merge migration - no changes needed
pass

View File

@@ -0,0 +1,65 @@
"""Add exiled games and skip_exile consumable
Revision ID: 030
Revises: 029
Create Date: 2025-01-10
"""
from alembic import op
from sqlalchemy import inspect
import sqlalchemy as sa
revision = '031_add_exiled_games'
down_revision = '030_merge_029_heads'
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade():
# Create exiled_games table if not exists
if not table_exists('exiled_games'):
op.create_table(
'exiled_games',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('participant_id', sa.Integer(), nullable=False),
sa.Column('game_id', sa.Integer(), nullable=False),
sa.Column('assignment_id', sa.Integer(), nullable=True),
sa.Column('exiled_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('exiled_by', sa.String(20), nullable=False),
sa.Column('reason', sa.String(500), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('unexiled_at', sa.DateTime(), nullable=True),
sa.Column('unexiled_by', sa.String(20), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['participant_id'], ['participants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['assignment_id'], ['assignments.id'], ondelete='SET NULL'),
sa.UniqueConstraint('participant_id', 'game_id', name='unique_participant_game_exile'),
)
op.create_index('ix_exiled_games_participant_id', 'exiled_games', ['participant_id'])
op.create_index('ix_exiled_games_active', 'exiled_games', ['participant_id', 'is_active'])
# Add skip_exile consumable to shop if not exists
op.execute("""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
SELECT 'consumable', 'skip_exile', 'Скип с изгнанием',
'Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула и больше не выпадет.',
150, 'rare', '{"effect": "skip_exile", "icon": "x-circle"}', true, NOW()
WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE code = 'skip_exile')
""")
def downgrade():
# Remove skip_exile from shop
op.execute("DELETE FROM shop_items WHERE code = 'skip_exile'")
# Drop exiled_games table
op.drop_index('ix_exiled_games_active', table_name='exiled_games')
op.drop_index('ix_exiled_games_participant_id', table_name='exiled_games')
op.drop_table('exiled_games')

View File

@@ -4,6 +4,7 @@ from datetime import datetime
from fastapi import Depends, HTTPException, status, Header from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
@@ -35,7 +36,16 @@ async def get_current_user(
detail="Invalid token payload", detail="Invalid token payload",
) )
result = await db.execute(select(User).where(User.id == int(user_id))) result = await db.execute(
select(User)
.where(User.id == int(user_id))
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content, shop, promo, widgets
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
@@ -16,3 +16,6 @@ router.include_router(events.router)
router.include_router(assignments.router) router.include_router(assignments.router)
router.include_router(telegram.router) router.include_router(telegram.router)
router.include_router(content.router) router.include_router(content.router)
router.include_router(shop.router)
router.include_router(promo.router)
router.include_router(widgets.router)

View File

@@ -7,7 +7,7 @@ from typing import Optional
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
from app.models import ( from app.models import (
User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent, User, UserRole, Marathon, MarathonStatus, CertificationStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
) )
from app.schemas import ( from app.schemas import (
@@ -37,6 +37,8 @@ class AdminMarathonResponse(BaseModel):
start_date: str | None start_date: str | None
end_date: str | None end_date: str | None
created_at: str created_at: str
certification_status: str = "none"
is_certified: bool = False
class Config: class Config:
from_attributes = True from_attributes = True
@@ -219,7 +221,12 @@ async def list_marathons(
query = ( query = (
select(Marathon) select(Marathon)
.options(selectinload(Marathon.creator)) .options(
selectinload(Marathon.creator).selectinload(User.equipped_frame),
selectinload(Marathon.creator).selectinload(User.equipped_title),
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
selectinload(Marathon.creator).selectinload(User.equipped_background),
)
.order_by(Marathon.created_at.desc()) .order_by(Marathon.created_at.desc())
) )
@@ -248,6 +255,8 @@ async def list_marathons(
start_date=marathon.start_date.isoformat() if marathon.start_date else None, start_date=marathon.start_date.isoformat() if marathon.start_date else None,
end_date=marathon.end_date.isoformat() if marathon.end_date else None, end_date=marathon.end_date.isoformat() if marathon.end_date else None,
created_at=marathon.created_at.isoformat(), created_at=marathon.created_at.isoformat(),
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
)) ))
return response return response
@@ -443,6 +452,8 @@ async def force_finish_marathon(
db: DbSession, db: DbSession,
): ):
"""Force finish a marathon. Admin only.""" """Force finish a marathon. Admin only."""
from app.services.coins import coins_service
require_admin_with_2fa(current_user) require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
@@ -456,6 +467,24 @@ async def force_finish_marathon(
old_status = marathon.status old_status = marathon.status
marathon.status = MarathonStatus.FINISHED.value marathon.status = MarathonStatus.FINISHED.value
marathon.end_date = datetime.utcnow() marathon.end_date = datetime.utcnow()
# Award coins for top 3 places (only in certified marathons)
if marathon.is_certified:
top_result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
.limit(3)
)
top_participants = top_result.scalars().all()
for place, participant in enumerate(top_participants, start=1):
if participant.total_points > 0:
await coins_service.award_marathon_place(
db, participant.user, marathon, place
)
await db.commit() await db.commit()
# Log action # Log action
@@ -1102,3 +1131,75 @@ async def resolve_dispute(
return MessageResponse( return MessageResponse(
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}" message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
) )
# ============ Marathon Certification ============
@router.post("/marathons/{marathon_id}/certify", response_model=MessageResponse)
async def certify_marathon(
request: Request,
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Certify (verify) a marathon. Admin only."""
require_admin_with_2fa(current_user)
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.certification_status == CertificationStatus.CERTIFIED.value:
raise HTTPException(status_code=400, detail="Marathon is already certified")
marathon.certification_status = CertificationStatus.CERTIFIED.value
marathon.certified_at = datetime.utcnow()
marathon.certified_by_id = current_user.id
marathon.certification_rejection_reason = None
await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.MARATHON_CERTIFY.value,
"marathon", marathon_id,
{"title": marathon.title},
request.client.host if request.client else None
)
return MessageResponse(message="Marathon certified successfully")
@router.post("/marathons/{marathon_id}/revoke-certification", response_model=MessageResponse)
async def revoke_marathon_certification(
request: Request,
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Revoke certification from a marathon. Admin only."""
require_admin_with_2fa(current_user)
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.certification_status != CertificationStatus.CERTIFIED.value:
raise HTTPException(status_code=400, detail="Marathon is not certified")
marathon.certification_status = CertificationStatus.NONE.value
marathon.certified_at = None
marathon.certified_by_id = None
await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.MARATHON_REVOKE_CERTIFICATION.value,
"marathon", marathon_id,
{"title": marathon.title},
request.client.host if request.client else None
)
return MessageResponse(message="Marathon certification revoked")

View File

@@ -3,6 +3,7 @@ import secrets
from fastapi import APIRouter, HTTPException, status, Request from fastapi import APIRouter, HTTPException, status, Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.security import verify_password, get_password_hash, create_access_token from app.core.security import verify_password, get_password_hash, create_access_token
@@ -48,7 +49,16 @@ async def register(request: Request, data: UserRegister, db: DbSession):
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def login(request: Request, data: UserLogin, db: DbSession): async def login(request: Request, data: UserLogin, db: DbSession):
# Find user # Find user
result = await db.execute(select(User).where(User.login == data.login.lower())) result = await db.execute(
select(User)
.where(User.login == data.login.lower())
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.password_hash): if not user or not verify_password(data.password, user.password_hash):
@@ -147,7 +157,16 @@ async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession
await db.commit() await db.commit()
# Get user # Get user
result = await db.execute(select(User).where(User.id == session.user_id)) result = await db.execute(
select(User)
.where(User.id == session.user_id)
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:

View File

@@ -366,7 +366,7 @@ async def save_challenges(
description=ch_data.description, description=ch_data.description,
type=ch_type, type=ch_type,
difficulty=difficulty, difficulty=difficulty,
points=max(1, min(500, ch_data.points)), points=max(1, ch_data.points),
estimated_time=ch_data.estimated_time, estimated_time=ch_data.estimated_time,
proof_type=proof_type, proof_type=proof_type,
proof_hint=ch_data.proof_hint, proof_hint=ch_data.proof_hint,

View File

@@ -10,6 +10,7 @@ from app.models import (
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game, Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
SwapRequest as SwapRequestModel, SwapRequestStatus, User, SwapRequest as SwapRequestModel, SwapRequestStatus, User,
) )
from app.models.bonus_assignment import BonusAssignment
from fastapi import UploadFile, File, Form from fastapi import UploadFile, File, Form
from app.schemas import ( from app.schemas import (
@@ -275,6 +276,26 @@ async def stop_event(
return MessageResponse(message="Event stopped") return MessageResponse(message="Event stopped")
def build_assignment_info(assignment: Assignment) -> SwapRequestChallengeInfo:
"""Build SwapRequestChallengeInfo from assignment (challenge or playthrough)"""
if assignment.is_playthrough:
return SwapRequestChallengeInfo(
is_playthrough=True,
playthrough_description=assignment.game.playthrough_description,
playthrough_points=assignment.game.playthrough_points,
game_title=assignment.game.title,
)
else:
return SwapRequestChallengeInfo(
is_playthrough=False,
title=assignment.challenge.title,
description=assignment.challenge.description,
points=assignment.challenge.points,
difficulty=assignment.challenge.difficulty,
game_title=assignment.challenge.game.title,
)
def build_swap_request_response( def build_swap_request_response(
swap_req: SwapRequestModel, swap_req: SwapRequestModel,
) -> SwapRequestResponse: ) -> SwapRequestResponse:
@@ -298,20 +319,8 @@ def build_swap_request_response(
role=swap_req.to_participant.user.role, role=swap_req.to_participant.user.role,
created_at=swap_req.to_participant.user.created_at, created_at=swap_req.to_participant.user.created_at,
), ),
from_challenge=SwapRequestChallengeInfo( from_challenge=build_assignment_info(swap_req.from_assignment),
title=swap_req.from_assignment.challenge.title, to_challenge=build_assignment_info(swap_req.to_assignment),
description=swap_req.from_assignment.challenge.description,
points=swap_req.from_assignment.challenge.points,
difficulty=swap_req.from_assignment.challenge.difficulty,
game_title=swap_req.from_assignment.challenge.game.title,
),
to_challenge=SwapRequestChallengeInfo(
title=swap_req.to_assignment.challenge.title,
description=swap_req.to_assignment.challenge.description,
points=swap_req.to_assignment.challenge.points,
difficulty=swap_req.to_assignment.challenge.difficulty,
game_title=swap_req.to_assignment.challenge.game.title,
),
created_at=swap_req.created_at, created_at=swap_req.created_at,
responded_at=swap_req.responded_at, responded_at=swap_req.responded_at,
) )
@@ -349,11 +358,12 @@ async def create_swap_request(
if target.id == participant.id: if target.id == participant.id:
raise HTTPException(status_code=400, detail="Cannot swap with yourself") raise HTTPException(status_code=400, detail="Cannot swap with yourself")
# Get both active assignments # Get both active assignments (with challenge.game or game for playthrough)
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
) )
.where( .where(
Assignment.participant_id == participant.id, Assignment.participant_id == participant.id,
@@ -365,7 +375,8 @@ async def create_swap_request(
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
) )
.where( .where(
Assignment.participant_id == target.id, Assignment.participant_id == target.id,
@@ -417,7 +428,7 @@ async def create_swap_request(
await db.commit() await db.commit()
await db.refresh(swap_request) await db.refresh(swap_request)
# Load relationships for response # Load relationships for response (including game for playthrough)
result = await db.execute( result = await db.execute(
select(SwapRequestModel) select(SwapRequestModel)
.options( .options(
@@ -426,9 +437,13 @@ async def create_swap_request(
selectinload(SwapRequestModel.from_assignment) selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.challenge) .selectinload(Assignment.challenge)
.selectinload(Challenge.game), .selectinload(Challenge.game),
selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.game),
selectinload(SwapRequestModel.to_assignment) selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.challenge) .selectinload(Assignment.challenge)
.selectinload(Challenge.game), .selectinload(Challenge.game),
selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.game),
) )
.where(SwapRequestModel.id == swap_request.id) .where(SwapRequestModel.id == swap_request.id)
) )
@@ -461,9 +476,13 @@ async def get_my_swap_requests(
selectinload(SwapRequestModel.from_assignment) selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.challenge) .selectinload(Assignment.challenge)
.selectinload(Challenge.game), .selectinload(Challenge.game),
selectinload(SwapRequestModel.from_assignment)
.selectinload(Assignment.game),
selectinload(SwapRequestModel.to_assignment) selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.challenge) .selectinload(Assignment.challenge)
.selectinload(Challenge.game), .selectinload(Challenge.game),
selectinload(SwapRequestModel.to_assignment)
.selectinload(Assignment.game),
) )
.where( .where(
SwapRequestModel.event_id == event.id, SwapRequestModel.event_id == event.id,
@@ -553,10 +572,39 @@ async def accept_swap_request(
await db.commit() await db.commit()
raise HTTPException(status_code=400, detail="One or both assignments are no longer active") raise HTTPException(status_code=400, detail="One or both assignments are no longer active")
# Perform the swap # Perform the swap (swap challenge_id, game_id, and is_playthrough)
from_challenge_id = from_assignment.challenge_id from_challenge_id = from_assignment.challenge_id
from_game_id = from_assignment.game_id
from_is_playthrough = from_assignment.is_playthrough
from_assignment.challenge_id = to_assignment.challenge_id from_assignment.challenge_id = to_assignment.challenge_id
from_assignment.game_id = to_assignment.game_id
from_assignment.is_playthrough = to_assignment.is_playthrough
to_assignment.challenge_id = from_challenge_id to_assignment.challenge_id = from_challenge_id
to_assignment.game_id = from_game_id
to_assignment.is_playthrough = from_is_playthrough
# Swap bonus assignments between the two assignments
from sqlalchemy import update as sql_update
# Get bonus assignments for both
from_bonus_result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == from_assignment.id)
)
from_bonus_assignments = from_bonus_result.scalars().all()
to_bonus_result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == to_assignment.id)
)
to_bonus_assignments = to_bonus_result.scalars().all()
# Move bonus assignments: from -> to, to -> from
for bonus in from_bonus_assignments:
bonus.main_assignment_id = to_assignment.id
for bonus in to_bonus_assignments:
bonus.main_assignment_id = from_assignment.id
# Update request status # Update request status
swap_request.status = SwapRequestStatus.ACCEPTED.value swap_request.status = SwapRequestStatus.ACCEPTED.value
@@ -865,8 +913,10 @@ async def get_swap_candidates(
if not event or event.type != EventType.SWAP.value: if not event or event.type != EventType.SWAP.value:
raise HTTPException(status_code=400, detail="No active swap event") raise HTTPException(status_code=400, detail="No active swap event")
# Get all participants except current user with active assignments
from app.models import Game from app.models import Game
candidates = []
# Get challenge-based assignments
result = await db.execute( result = await db.execute(
select(Participant, Assignment, Challenge, Game) select(Participant, Assignment, Challenge, Game)
.join(Assignment, Assignment.participant_id == Participant.id) .join(Assignment, Assignment.participant_id == Participant.id)
@@ -877,12 +927,11 @@ async def get_swap_candidates(
Participant.marathon_id == marathon_id, Participant.marathon_id == marathon_id,
Participant.id != participant.id, Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value, Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_playthrough == False,
) )
) )
rows = result.all() for p, assignment, challenge, game in result.all():
candidates.append(SwapCandidate(
return [
SwapCandidate(
participant_id=p.id, participant_id=p.id,
user=UserPublic( user=UserPublic(
id=p.user.id, id=p.user.id,
@@ -892,14 +941,45 @@ async def get_swap_candidates(
role=p.user.role, role=p.user.role,
created_at=p.user.created_at, created_at=p.user.created_at,
), ),
is_playthrough=False,
challenge_title=challenge.title, challenge_title=challenge.title,
challenge_description=challenge.description, challenge_description=challenge.description,
challenge_points=challenge.points, challenge_points=challenge.points,
challenge_difficulty=challenge.difficulty, challenge_difficulty=challenge.difficulty,
game_title=game.title, game_title=game.title,
))
# Get playthrough-based assignments
result = await db.execute(
select(Participant, Assignment, Game)
.join(Assignment, Assignment.participant_id == Participant.id)
.join(Game, Assignment.game_id == Game.id)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_playthrough == True,
) )
for p, assignment, challenge, game in rows )
] for p, assignment, game in result.all():
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
login=p.user.login,
nickname=p.user.nickname,
avatar_url=None,
role=p.user.role,
created_at=p.user.created_at,
),
is_playthrough=True,
playthrough_description=game.playthrough_description,
playthrough_points=game.playthrough_points,
game_title=game.title,
))
return candidates
@router.get("/marathons/{marathon_id}/common-enemy-leaderboard", response_model=list[CommonEnemyLeaderboard]) @router.get("/marathons/{marathon_id}/common-enemy-leaderboard", response_model=list[CommonEnemyLeaderboard])
@@ -993,6 +1073,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
streak_at_completion=assignment.streak_at_completion, streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at, started_at=assignment.started_at,
completed_at=assignment.completed_at, completed_at=assignment.completed_at,
tracked_time_minutes=assignment.tracked_time_minutes,
) )
# Regular challenge assignment # Regular challenge assignment
@@ -1026,6 +1107,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
streak_at_completion=assignment.streak_at_completion, streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at, started_at=assignment.started_at,
completed_at=assignment.completed_at, completed_at=assignment.completed_at,
tracked_time_minutes=assignment.tracked_time_minutes,
) )

View File

@@ -3,7 +3,7 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.models import Activity, Participant, Dispute, ActivityType from app.models import Activity, Participant, Dispute, ActivityType, User
from app.models.dispute import DisputeStatus from app.models.dispute import DisputeStatus
from app.schemas import FeedResponse, ActivityResponse, UserPublic from app.schemas import FeedResponse, ActivityResponse, UserPublic
@@ -37,7 +37,12 @@ async def get_feed(
# Get activities # Get activities
result = await db.execute( result = await db.execute(
select(Activity) select(Activity)
.options(selectinload(Activity.user)) .options(
selectinload(Activity.user).selectinload(User.equipped_frame),
selectinload(Activity.user).selectinload(User.equipped_title),
selectinload(Activity.user).selectinload(User.equipped_name_color),
selectinload(Activity.user).selectinload(User.equipped_background),
)
.where(Activity.marathon_id == marathon_id) .where(Activity.marathon_id == marathon_id)
.order_by(Activity.created_at.desc()) .order_by(Activity.created_at.desc())
.limit(limit) .limit(limit)

View File

@@ -9,7 +9,8 @@ from app.api.deps import (
from app.core.config import settings from app.core.config import settings
from app.models import ( from app.models import (
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType, Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User,
ExiledGame
) )
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
from app.schemas.assignment import AvailableGamesCount from app.schemas.assignment import AvailableGamesCount
@@ -23,8 +24,14 @@ async def get_game_or_404(db, game_id: int) -> Game:
result = await db.execute( result = await db.execute(
select(Game) select(Game)
.options( .options(
selectinload(Game.proposed_by), selectinload(Game.proposed_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by), selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
) )
.where(Game.id == game_id) .where(Game.id == game_id)
) )
@@ -73,8 +80,14 @@ async def list_games(
select(Game, func.count(Challenge.id).label("challenges_count")) select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge) .outerjoin(Challenge)
.options( .options(
selectinload(Game.proposed_by), selectinload(Game.proposed_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by), selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
) )
.where(Game.marathon_id == marathon_id) .where(Game.marathon_id == marathon_id)
.group_by(Game.id) .group_by(Game.id)
@@ -106,8 +119,14 @@ async def list_pending_games(marathon_id: int, current_user: CurrentUser, db: Db
select(Game, func.count(Challenge.id).label("challenges_count")) select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge) .outerjoin(Challenge)
.options( .options(
selectinload(Game.proposed_by), selectinload(Game.proposed_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by), selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
) )
.where( .where(
Game.marathon_id == marathon_id, Game.marathon_id == marathon_id,
@@ -501,9 +520,23 @@ async def get_available_games_for_participant(
) )
completed_challenge_ids = set(challenges_result.scalars().all()) completed_challenge_ids = set(challenges_result.scalars().all())
# Получаем изгнанные игры (is_active=True означает что игра изгнана)
exiled_result = await db.execute(
select(ExiledGame.game_id)
.where(
ExiledGame.participant_id == participant.id,
ExiledGame.is_active == True,
)
)
exiled_game_ids = set(exiled_result.scalars().all())
# Фильтруем доступные игры # Фильтруем доступные игры
available_games = [] available_games = []
for game in games_with_content: for game in games_with_content:
# Исключаем изгнанные игры
if game.id in exiled_game_ids:
continue
if game.game_type == GameType.PLAYTHROUGH.value: if game.game_type == GameType.PLAYTHROUGH.value:
# Исключаем если игра уже завершена/дропнута # Исключаем если игра уже завершена/дропнута
if game.id not in finished_playthrough_game_ids: if game.id not in finished_playthrough_game_ids:

View File

@@ -20,7 +20,8 @@ optional_auth = HTTPBearer(auto_error=False)
from app.models import ( from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge, Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
ExiledGame,
) )
from app.schemas import ( from app.schemas import (
MarathonCreate, MarathonCreate,
@@ -35,6 +36,8 @@ from app.schemas import (
MessageResponse, MessageResponse,
UserPublic, UserPublic,
SetParticipantRole, SetParticipantRole,
OrganizerSkipRequest,
ExiledGameResponse,
) )
from app.services.telegram_notifier import telegram_notifier from app.services.telegram_notifier import telegram_notifier
@@ -80,7 +83,12 @@ def generate_invite_code() -> str:
async def get_marathon_or_404(db, marathon_id: int) -> Marathon: async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
result = await db.execute( result = await db.execute(
select(Marathon) select(Marathon)
.options(selectinload(Marathon.creator)) .options(
selectinload(Marathon.creator).selectinload(User.equipped_frame),
selectinload(Marathon.creator).selectinload(User.equipped_title),
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
selectinload(Marathon.creator).selectinload(User.equipped_background),
)
.where(Marathon.id == marathon_id) .where(Marathon.id == marathon_id)
) )
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()
@@ -348,6 +356,8 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
@router.post("/{marathon_id}/finish", response_model=MarathonResponse) @router.post("/{marathon_id}/finish", response_model=MarathonResponse)
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
from app.services.coins import coins_service
# Require organizer role # Require organizer role
await require_organizer(db, current_user, marathon_id) await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id) marathon = await get_marathon_or_404(db, marathon_id)
@@ -357,6 +367,24 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
marathon.status = MarathonStatus.FINISHED.value marathon.status = MarathonStatus.FINISHED.value
# Award coins for top 3 places (only in certified marathons)
if marathon.is_certified:
# Get top 3 participants by total_points
top_result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
.limit(3)
)
top_participants = top_result.scalars().all()
for place, participant in enumerate(top_participants, start=1):
if participant.total_points > 0: # Only award if they have points
await coins_service.award_marathon_place(
db, participant.user, marathon, place
)
# Log activity # Log activity
activity = Activity( activity = Activity(
marathon_id=marathon_id, marathon_id=marathon_id,
@@ -465,7 +493,12 @@ async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSe
result = await db.execute( result = await db.execute(
select(Participant) select(Participant)
.options(selectinload(Participant.user)) .options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where(Participant.marathon_id == marathon_id) .where(Participant.marathon_id == marathon_id)
.order_by(Participant.joined_at) .order_by(Participant.joined_at)
) )
@@ -504,7 +537,12 @@ async def set_participant_role(
# Get participant # Get participant
result = await db.execute( result = await db.execute(
select(Participant) select(Participant)
.options(selectinload(Participant.user)) .options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where( .where(
Participant.marathon_id == marathon_id, Participant.marathon_id == marathon_id,
Participant.user_id == user_id, Participant.user_id == user_id,
@@ -569,7 +607,12 @@ async def get_leaderboard(
result = await db.execute( result = await db.execute(
select(Participant) select(Participant)
.options(selectinload(Participant.user)) .options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where(Participant.marathon_id == marathon_id) .where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc()) .order_by(Participant.total_points.desc())
) )
@@ -964,3 +1007,224 @@ async def resolve_marathon_dispute(
return MessageResponse( return MessageResponse(
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}" message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
) )
# ============= Moderation Endpoints =============
@router.post("/{marathon_id}/participants/{user_id}/skip-assignment", response_model=MessageResponse)
async def organizer_skip_assignment(
marathon_id: int,
user_id: int,
data: OrganizerSkipRequest,
current_user: CurrentUser,
db: DbSession,
):
"""
Organizer skips a participant's current assignment.
- No penalty for participant
- Streak is preserved
- Optionally exile the game from participant's pool
"""
await require_organizer(db, current_user, marathon_id)
# Get 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")
# Get target participant
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
# Get active assignment (exclude event assignments)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game),
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_event_assignment == False,
)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=400, detail="Participant has no active assignment")
# Get game info
if assignment.is_playthrough:
game = assignment.game
game_id = game.id
game_title = game.title
else:
game = assignment.challenge.game
game_id = game.id
game_title = game.title
# Skip the assignment (no penalty)
from datetime import datetime
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Note: We do NOT reset streak or increment drop_count
# Exile the game if requested
if data.exile:
# Check if already exiled
existing = await db.execute(
select(ExiledGame).where(
ExiledGame.participant_id == participant.id,
ExiledGame.game_id == game_id,
ExiledGame.is_active == True,
)
)
if not existing.scalar_one_or_none():
exiled = ExiledGame(
participant_id=participant.id,
game_id=game_id,
assignment_id=assignment.id,
exiled_by="organizer",
reason=data.reason,
)
db.add(exiled)
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.MODERATION.value,
data={
"action": "skip_assignment",
"target_user_id": user_id,
"target_nickname": participant.user.nickname,
"assignment_id": assignment.id,
"game_id": game_id,
"game_title": game_title,
"exile": data.exile,
"reason": data.reason,
}
)
db.add(activity)
await db.commit()
# Send notification
await telegram_notifier.notify_assignment_skipped_by_moderator(
db,
user=participant.user,
marathon_title=marathon.title,
game_title=game_title,
exiled=data.exile,
reason=data.reason,
moderator_nickname=current_user.nickname,
)
exile_msg = " and exiled from pool" if data.exile else ""
return MessageResponse(message=f"Assignment skipped{exile_msg}")
@router.get("/{marathon_id}/participants/{user_id}/exiled-games", response_model=list[ExiledGameResponse])
async def get_participant_exiled_games(
marathon_id: int,
user_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get list of exiled games for a participant (organizers only)"""
await require_organizer(db, current_user, marathon_id)
# Get participant
result = await db.execute(
select(Participant).where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
# Get exiled games
result = await db.execute(
select(ExiledGame)
.options(selectinload(ExiledGame.game))
.where(
ExiledGame.participant_id == participant.id,
ExiledGame.is_active == True,
)
.order_by(ExiledGame.exiled_at.desc())
)
exiled_games = result.scalars().all()
return [
ExiledGameResponse(
id=eg.id,
game_id=eg.game_id,
game_title=eg.game.title,
exiled_at=eg.exiled_at,
exiled_by=eg.exiled_by,
reason=eg.reason,
)
for eg in exiled_games
]
@router.post("/{marathon_id}/participants/{user_id}/exiled-games/{game_id}/restore", response_model=MessageResponse)
async def restore_exiled_game(
marathon_id: int,
user_id: int,
game_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Restore an exiled game back to participant's pool (organizers only)"""
await require_organizer(db, current_user, marathon_id)
# Get participant
result = await db.execute(
select(Participant).where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
# Get exiled game
result = await db.execute(
select(ExiledGame)
.options(selectinload(ExiledGame.game))
.where(
ExiledGame.participant_id == participant.id,
ExiledGame.game_id == game_id,
ExiledGame.is_active == True,
)
)
exiled_game = result.scalar_one_or_none()
if not exiled_game:
raise HTTPException(status_code=404, detail="Exiled game not found")
# Restore (soft-delete)
from datetime import datetime
exiled_game.is_active = False
exiled_game.unexiled_at = datetime.utcnow()
exiled_game.unexiled_by = "organizer"
await db.commit()
return MessageResponse(message=f"Game '{exiled_game.game.title}' restored to pool")

299
backend/app/api/v1/promo.py Normal file
View File

@@ -0,0 +1,299 @@
"""
Promo Code API endpoints - user redemption and admin management
"""
import secrets
import string
from datetime import datetime
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import CurrentUser, DbSession, require_admin_with_2fa
from app.models import User, CoinTransaction, CoinTransactionType
from app.models.promo_code import PromoCode, PromoCodeRedemption
from app.schemas.promo_code import (
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeResponse,
PromoCodeRedeemRequest,
PromoCodeRedeemResponse,
PromoCodeRedemptionResponse,
PromoCodeRedemptionUser,
)
from app.schemas.common import MessageResponse
router = APIRouter(prefix="/promo", tags=["promo"])
def generate_promo_code(length: int = 8) -> str:
"""Generate a random promo code"""
chars = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(chars) for _ in range(length))
# === User endpoints ===
@router.post("/redeem", response_model=PromoCodeRedeemResponse)
async def redeem_promo_code(
data: PromoCodeRedeemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Redeem a promo code to receive coins"""
# Find promo code
result = await db.execute(
select(PromoCode).where(PromoCode.code == data.code.upper().strip())
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
# Check if valid
if not promo.is_active:
raise HTTPException(status_code=400, detail="Промокод деактивирован")
now = datetime.utcnow()
if promo.valid_from and now < promo.valid_from:
raise HTTPException(status_code=400, detail="Промокод ещё не активен")
if promo.valid_until and now > promo.valid_until:
raise HTTPException(status_code=400, detail="Промокод истёк")
if promo.max_uses is not None and promo.uses_count >= promo.max_uses:
raise HTTPException(status_code=400, detail="Лимит использований исчерпан")
# Check if user already redeemed
result = await db.execute(
select(PromoCodeRedemption).where(
PromoCodeRedemption.promo_code_id == promo.id,
PromoCodeRedemption.user_id == current_user.id,
)
)
existing = result.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail="Вы уже использовали этот промокод")
# Create redemption record
redemption = PromoCodeRedemption(
promo_code_id=promo.id,
user_id=current_user.id,
coins_awarded=promo.coins_amount,
)
db.add(redemption)
# Update uses count
promo.uses_count += 1
# Award coins
transaction = CoinTransaction(
user_id=current_user.id,
amount=promo.coins_amount,
transaction_type=CoinTransactionType.PROMO_CODE.value,
reference_type="promo_code",
reference_id=promo.id,
description=f"Промокод: {promo.code}",
)
db.add(transaction)
current_user.coins_balance += promo.coins_amount
await db.commit()
await db.refresh(current_user)
return PromoCodeRedeemResponse(
success=True,
coins_awarded=promo.coins_amount,
new_balance=current_user.coins_balance,
message=f"Вы получили {promo.coins_amount} монет!",
)
# === Admin endpoints ===
@router.get("/admin/list", response_model=list[PromoCodeResponse])
async def admin_list_promo_codes(
current_user: CurrentUser,
db: DbSession,
include_inactive: bool = False,
):
"""Get all promo codes (admin only)"""
require_admin_with_2fa(current_user)
query = select(PromoCode).options(selectinload(PromoCode.created_by))
if not include_inactive:
query = query.where(PromoCode.is_active == True)
query = query.order_by(PromoCode.created_at.desc())
result = await db.execute(query)
promos = result.scalars().all()
return [
PromoCodeResponse(
id=p.id,
code=p.code,
coins_amount=p.coins_amount,
max_uses=p.max_uses,
uses_count=p.uses_count,
is_active=p.is_active,
valid_from=p.valid_from,
valid_until=p.valid_until,
created_at=p.created_at,
created_by_nickname=p.created_by.nickname if p.created_by else None,
)
for p in promos
]
@router.post("/admin/create", response_model=PromoCodeResponse)
async def admin_create_promo_code(
data: PromoCodeCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a new promo code (admin only)"""
require_admin_with_2fa(current_user)
# Generate or use provided code
code = data.code.upper().strip() if data.code else generate_promo_code()
# Check uniqueness
result = await db.execute(
select(PromoCode).where(PromoCode.code == code)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"Промокод '{code}' уже существует")
promo = PromoCode(
code=code,
coins_amount=data.coins_amount,
max_uses=data.max_uses,
valid_from=data.valid_from,
valid_until=data.valid_until,
created_by_id=current_user.id,
)
db.add(promo)
await db.commit()
await db.refresh(promo)
return PromoCodeResponse(
id=promo.id,
code=promo.code,
coins_amount=promo.coins_amount,
max_uses=promo.max_uses,
uses_count=promo.uses_count,
is_active=promo.is_active,
valid_from=promo.valid_from,
valid_until=promo.valid_until,
created_at=promo.created_at,
created_by_nickname=current_user.nickname,
)
@router.put("/admin/{promo_id}", response_model=PromoCodeResponse)
async def admin_update_promo_code(
promo_id: int,
data: PromoCodeUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update a promo code (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(PromoCode)
.options(selectinload(PromoCode.created_by))
.where(PromoCode.id == promo_id)
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
if data.is_active is not None:
promo.is_active = data.is_active
if data.max_uses is not None:
promo.max_uses = data.max_uses
if data.valid_until is not None:
promo.valid_until = data.valid_until
await db.commit()
await db.refresh(promo)
return PromoCodeResponse(
id=promo.id,
code=promo.code,
coins_amount=promo.coins_amount,
max_uses=promo.max_uses,
uses_count=promo.uses_count,
is_active=promo.is_active,
valid_from=promo.valid_from,
valid_until=promo.valid_until,
created_at=promo.created_at,
created_by_nickname=promo.created_by.nickname if promo.created_by else None,
)
@router.delete("/admin/{promo_id}", response_model=MessageResponse)
async def admin_delete_promo_code(
promo_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete a promo code (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(PromoCode).where(PromoCode.id == promo_id)
)
promo = result.scalar_one_or_none()
if not promo:
raise HTTPException(status_code=404, detail="Промокод не найден")
await db.delete(promo)
await db.commit()
return MessageResponse(message=f"Промокод '{promo.code}' удалён")
@router.get("/admin/{promo_id}/redemptions", response_model=list[PromoCodeRedemptionResponse])
async def admin_get_promo_redemptions(
promo_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get list of users who redeemed a promo code (admin only)"""
require_admin_with_2fa(current_user)
# Check promo exists
result = await db.execute(
select(PromoCode).where(PromoCode.id == promo_id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Промокод не найден")
# Get redemptions
result = await db.execute(
select(PromoCodeRedemption)
.options(selectinload(PromoCodeRedemption.user))
.where(PromoCodeRedemption.promo_code_id == promo_id)
.order_by(PromoCodeRedemption.redeemed_at.desc())
)
redemptions = result.scalars().all()
return [
PromoCodeRedemptionResponse(
id=r.id,
user=PromoCodeRedemptionUser(
id=r.user.id,
nickname=r.user.nickname,
),
coins_awarded=r.coins_awarded,
redeemed_at=r.redeemed_at,
)
for r in redemptions
]

904
backend/app/api/v1/shop.py Normal file
View File

@@ -0,0 +1,904 @@
"""
Shop API endpoints - catalog, purchases, inventory, cosmetics, consumables
"""
from datetime import datetime
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import CurrentUser, DbSession, require_participant, require_admin_with_2fa
from app.models import (
User, Marathon, Participant, Assignment, AssignmentStatus,
ShopItem, UserInventory, CoinTransaction, ShopItemType,
CertificationStatus, Challenge, Game,
)
from app.schemas import (
ShopItemResponse, ShopItemCreate, ShopItemUpdate,
InventoryItemResponse, PurchaseRequest, PurchaseResponse,
UseConsumableRequest, UseConsumableResponse,
EquipItemRequest, EquipItemResponse,
CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest,
CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse,
ConsumablesStatusResponse, MessageResponse, SwapCandidate,
AdminGrantItemRequest,
)
from app.schemas.user import UserPublic
from app.services.shop import shop_service
from app.services.coins import coins_service
from app.services.consumables import consumables_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/shop", tags=["shop"])
# === Catalog ===
@router.get("/items", response_model=list[ShopItemResponse])
async def get_shop_items(
current_user: CurrentUser,
db: DbSession,
item_type: str | None = None,
include_unavailable: bool = False,
):
"""Get list of shop items"""
items = await shop_service.get_available_items(db, item_type, include_unavailable)
# Get user's inventory to mark owned/equipped items
user_inventory = await shop_service.get_user_inventory(db, current_user.id)
owned_ids = {inv.item_id for inv in user_inventory}
equipped_ids = {inv.item_id for inv in user_inventory if inv.equipped}
result = []
for item in items:
item_dict = ShopItemResponse.model_validate(item).model_dump()
item_dict["is_owned"] = item.id in owned_ids
item_dict["is_equipped"] = item.id in equipped_ids
result.append(ShopItemResponse(**item_dict))
return result
@router.get("/items/{item_id}", response_model=ShopItemResponse)
async def get_shop_item(
item_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get single shop item by ID"""
item = await shop_service.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
is_owned = await shop_service.check_user_owns_item(db, current_user.id, item_id)
# Check if equipped
is_equipped = False
if is_owned:
inventory = await shop_service.get_user_inventory(db, current_user.id, item.item_type)
is_equipped = any(inv.equipped and inv.item_id == item_id for inv in inventory)
response = ShopItemResponse.model_validate(item)
response.is_owned = is_owned
response.is_equipped = is_equipped
return response
# === Purchases ===
@router.post("/purchase", response_model=PurchaseResponse)
async def purchase_item(
data: PurchaseRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Purchase an item from the shop"""
inv_item, total_cost = await shop_service.purchase_item(
db, current_user, data.item_id, data.quantity
)
await db.commit()
await db.refresh(current_user)
item = await shop_service.get_item_by_id(db, data.item_id)
return PurchaseResponse(
success=True,
item=ShopItemResponse.model_validate(item),
quantity=data.quantity,
total_cost=total_cost,
new_balance=current_user.coins_balance,
message=f"Successfully purchased {item.name} x{data.quantity}",
)
# === Inventory ===
@router.get("/inventory", response_model=list[InventoryItemResponse])
async def get_my_inventory(
current_user: CurrentUser,
db: DbSession,
item_type: str | None = None,
):
"""Get current user's inventory"""
inventory = await shop_service.get_user_inventory(db, current_user.id, item_type)
return [InventoryItemResponse.model_validate(inv) for inv in inventory]
# === Equip/Unequip ===
@router.post("/equip", response_model=EquipItemResponse)
async def equip_item(
data: EquipItemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Equip a cosmetic item from inventory"""
item = await shop_service.equip_item(db, current_user, data.inventory_id)
await db.commit()
return EquipItemResponse(
success=True,
item_type=item.item_type,
equipped_item=ShopItemResponse.model_validate(item),
message=f"Equipped {item.name}",
)
@router.post("/unequip/{item_type}", response_model=EquipItemResponse)
async def unequip_item(
item_type: str,
current_user: CurrentUser,
db: DbSession,
):
"""Unequip item of specified type"""
valid_types = [ShopItemType.FRAME.value, ShopItemType.TITLE.value,
ShopItemType.NAME_COLOR.value, ShopItemType.BACKGROUND.value]
if item_type not in valid_types:
raise HTTPException(status_code=400, detail=f"Invalid item type: {item_type}")
await shop_service.unequip_item(db, current_user, item_type)
await db.commit()
return EquipItemResponse(
success=True,
item_type=item_type,
equipped_item=None,
message=f"Unequipped {item_type}",
)
# === Consumables ===
@router.post("/use", response_model=UseConsumableResponse)
async def use_consumable(
data: UseConsumableRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Use a consumable item"""
# Get marathon
result = await db.execute(select(Marathon).where(Marathon.id == data.marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
# Get participant
participant = await require_participant(db, current_user.id, data.marathon_id)
# For some consumables, we need the assignment
assignment = None
if data.item_code in ["skip", "skip_exile", "wild_card", "copycat"]:
if not data.assignment_id:
raise HTTPException(status_code=400, detail=f"assignment_id is required for {data.item_code}")
# For copycat and wild_card, we need bonus_assignments to properly handle playthrough
if data.item_code in ("copycat", "wild_card"):
result = await db.execute(
select(Assignment)
.options(selectinload(Assignment.bonus_assignments))
.where(
Assignment.id == data.assignment_id,
Assignment.participant_id == participant.id,
)
)
else:
result = await db.execute(
select(Assignment).where(
Assignment.id == data.assignment_id,
Assignment.participant_id == participant.id,
)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Use the consumable
if data.item_code == "skip":
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
effect_description = "Assignment skipped without penalty"
elif data.item_code == "skip_exile":
effect = await consumables_service.use_skip_exile(db, current_user, participant, marathon, assignment)
effect_description = "Assignment skipped, game exiled from pool"
elif data.item_code == "boost":
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
effect_description = f"Boost x{effect['multiplier']} activated for current assignment"
elif data.item_code == "wild_card":
if data.game_id is None:
raise HTTPException(status_code=400, detail="game_id is required for wild_card")
effect = await consumables_service.use_wild_card(
db, current_user, participant, marathon, assignment, data.game_id
)
effect_description = f"New challenge from {effect['game_name']}: {effect['challenge_title']}"
elif data.item_code == "lucky_dice":
effect = await consumables_service.use_lucky_dice(db, current_user, participant, marathon)
effect_description = f"Lucky Dice rolled: x{effect['multiplier']} multiplier"
elif data.item_code == "copycat":
if data.target_participant_id is None:
raise HTTPException(status_code=400, detail="target_participant_id is required for copycat")
effect = await consumables_service.use_copycat(
db, current_user, participant, marathon, assignment, data.target_participant_id
)
effect_description = f"Copied challenge: {effect['challenge_title']}"
elif data.item_code == "undo":
effect = await consumables_service.use_undo(db, current_user, participant, marathon)
effect_description = f"Restored {effect['points_restored']} points and streak {effect['streak_restored']}"
else:
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
await db.commit()
# Get remaining quantity
remaining = await consumables_service.get_consumable_count(db, current_user.id, data.item_code)
return UseConsumableResponse(
success=True,
item_code=data.item_code,
remaining_quantity=remaining,
effect_description=effect_description,
effect_data=effect,
)
@router.get("/consumables/{marathon_id}", response_model=ConsumablesStatusResponse)
async def get_consumables_status(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get consumables status for participant in 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")
participant = await require_participant(db, current_user.id, marathon_id)
# Get inventory counts for all consumables
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
skip_exiles_available = await consumables_service.get_consumable_count(db, current_user.id, "skip_exile")
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
wild_cards_available = await consumables_service.get_consumable_count(db, current_user.id, "wild_card")
lucky_dice_available = await consumables_service.get_consumable_count(db, current_user.id, "lucky_dice")
copycats_available = await consumables_service.get_consumable_count(db, current_user.id, "copycat")
undos_available = await consumables_service.get_consumable_count(db, current_user.id, "undo")
# Calculate remaining skips for this marathon
skips_remaining = None
if marathon.max_skips_per_participant is not None:
skips_remaining = max(0, marathon.max_skips_per_participant - participant.skips_used)
return ConsumablesStatusResponse(
skips_available=skips_available,
skip_exiles_available=skip_exiles_available,
skips_used=participant.skips_used,
skips_remaining=skips_remaining,
boosts_available=boosts_available,
has_active_boost=participant.has_active_boost,
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
wild_cards_available=wild_cards_available,
lucky_dice_available=lucky_dice_available,
has_lucky_dice=participant.has_lucky_dice,
lucky_dice_multiplier=participant.lucky_dice_multiplier,
copycats_available=copycats_available,
undos_available=undos_available,
can_undo=participant.can_undo,
)
@router.get("/copycat-candidates/{marathon_id}", response_model=list[SwapCandidate])
async def get_copycat_candidates(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get participants with active assignments available for copycat (no event required)"""
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")
participant = await require_participant(db, current_user.id, marathon_id)
# Get all participants except current user with active assignments
# Support both challenge assignments and playthrough assignments
result = await db.execute(
select(Participant, Assignment, Challenge, Game)
.join(Assignment, Assignment.participant_id == Participant.id)
.outerjoin(Challenge, Assignment.challenge_id == Challenge.id)
.outerjoin(Game, Challenge.game_id == Game.id)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.id != participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
rows = result.all()
candidates = []
for p, assignment, challenge, game in rows:
# For playthrough assignments, challenge is None
if assignment.is_playthrough:
# Need to get game info for playthrough
game_result = await db.execute(
select(Game).where(Game.id == assignment.game_id)
)
playthrough_game = game_result.scalar_one_or_none()
if playthrough_game:
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
nickname=p.user.nickname,
avatar_url=p.user.avatar_url,
role=p.user.role,
telegram_avatar_url=p.user.telegram_avatar_url,
created_at=p.user.created_at,
equipped_frame=None,
equipped_title=None,
equipped_name_color=None,
equipped_background=None,
),
challenge_title=f"Прохождение: {playthrough_game.title}",
challenge_description=playthrough_game.playthrough_description or "Прохождение игры",
challenge_points=playthrough_game.playthrough_points or 0,
challenge_difficulty="medium",
game_title=playthrough_game.title,
))
elif challenge and game:
candidates.append(SwapCandidate(
participant_id=p.id,
user=UserPublic(
id=p.user.id,
nickname=p.user.nickname,
avatar_url=p.user.avatar_url,
role=p.user.role,
telegram_avatar_url=p.user.telegram_avatar_url,
created_at=p.user.created_at,
equipped_frame=None,
equipped_title=None,
equipped_name_color=None,
equipped_background=None,
),
challenge_title=challenge.title,
challenge_description=challenge.description,
challenge_points=challenge.points,
challenge_difficulty=challenge.difficulty,
game_title=game.title,
))
return candidates
# === Coins ===
@router.get("/balance", response_model=CoinsBalanceResponse)
async def get_coins_balance(
current_user: CurrentUser,
db: DbSession,
):
"""Get current user's coins balance with recent transactions"""
result = await db.execute(
select(CoinTransaction)
.where(CoinTransaction.user_id == current_user.id)
.order_by(CoinTransaction.created_at.desc())
.limit(10)
)
transactions = result.scalars().all()
return CoinsBalanceResponse(
balance=current_user.coins_balance,
recent_transactions=[CoinTransactionResponse.model_validate(t) for t in transactions],
)
@router.get("/transactions", response_model=list[CoinTransactionResponse])
async def get_coin_transactions(
current_user: CurrentUser,
db: DbSession,
limit: int = 50,
offset: int = 0,
):
"""Get user's coin transaction history"""
result = await db.execute(
select(CoinTransaction)
.where(CoinTransaction.user_id == current_user.id)
.order_by(CoinTransaction.created_at.desc())
.offset(offset)
.limit(min(limit, 100))
)
transactions = result.scalars().all()
return [CoinTransactionResponse.model_validate(t) for t in transactions]
# === Certification (organizer endpoints) ===
@router.post("/certification/{marathon_id}/request", response_model=CertificationStatusResponse)
async def request_certification(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Request certification for a marathon (organizer only)"""
# Check user is organizer
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.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only the creator can request certification")
if marathon.certification_status != CertificationStatus.NONE.value:
raise HTTPException(
status_code=400,
detail=f"Marathon already has certification status: {marathon.certification_status}"
)
marathon.certification_status = CertificationStatus.PENDING.value
marathon.certification_requested_at = datetime.utcnow()
await db.commit()
await db.refresh(marathon)
return CertificationStatusResponse(
marathon_id=marathon.id,
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
certification_requested_at=marathon.certification_requested_at,
certified_at=marathon.certified_at,
certified_by_nickname=None,
rejection_reason=None,
)
@router.delete("/certification/{marathon_id}/request", response_model=MessageResponse)
async def cancel_certification_request(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Cancel certification request (organizer only)"""
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.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only the creator can cancel certification request")
if marathon.certification_status != CertificationStatus.PENDING.value:
raise HTTPException(status_code=400, detail="No pending certification request to cancel")
marathon.certification_status = CertificationStatus.NONE.value
marathon.certification_requested_at = None
await db.commit()
return MessageResponse(message="Certification request cancelled")
@router.get("/certification/{marathon_id}", response_model=CertificationStatusResponse)
async def get_certification_status(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get certification status of a marathon"""
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.certified_by))
.where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
return CertificationStatusResponse(
marathon_id=marathon.id,
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
certification_requested_at=marathon.certification_requested_at,
certified_at=marathon.certified_at,
certified_by_nickname=marathon.certified_by.nickname if marathon.certified_by else None,
rejection_reason=marathon.certification_rejection_reason,
)
# === Admin endpoints ===
@router.get("/admin/items", response_model=list[ShopItemResponse])
async def admin_get_all_items(
current_user: CurrentUser,
db: DbSession,
):
"""Get all shop items including inactive (admin only)"""
require_admin_with_2fa(current_user)
items = await shop_service.get_available_items(db, include_unavailable=True)
return [ShopItemResponse.model_validate(item) for item in items]
@router.post("/admin/items", response_model=ShopItemResponse)
async def admin_create_item(
data: ShopItemCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a new shop item (admin only)"""
require_admin_with_2fa(current_user)
# Check code uniqueness
existing = await shop_service.get_item_by_code(db, data.code)
if existing:
raise HTTPException(status_code=400, detail=f"Item with code '{data.code}' already exists")
item = ShopItem(
item_type=data.item_type,
code=data.code,
name=data.name,
description=data.description,
price=data.price,
rarity=data.rarity,
asset_data=data.asset_data,
is_active=data.is_active,
available_from=data.available_from,
available_until=data.available_until,
stock_limit=data.stock_limit,
stock_remaining=data.stock_limit, # Initialize remaining = limit
)
db.add(item)
await db.commit()
await db.refresh(item)
return ShopItemResponse.model_validate(item)
@router.put("/admin/items/{item_id}", response_model=ShopItemResponse)
async def admin_update_item(
item_id: int,
data: ShopItemUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update a shop item (admin only)"""
require_admin_with_2fa(current_user)
item = await shop_service.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
# Update fields
if data.name is not None:
item.name = data.name
if data.description is not None:
item.description = data.description
if data.price is not None:
item.price = data.price
if data.rarity is not None:
item.rarity = data.rarity
if data.asset_data is not None:
item.asset_data = data.asset_data
if data.is_active is not None:
item.is_active = data.is_active
if data.available_from is not None:
item.available_from = data.available_from
if data.available_until is not None:
item.available_until = data.available_until
if data.stock_limit is not None:
# If increasing limit, also increase remaining
if item.stock_limit is not None and data.stock_limit > item.stock_limit:
diff = data.stock_limit - item.stock_limit
item.stock_remaining = (item.stock_remaining or 0) + diff
item.stock_limit = data.stock_limit
await db.commit()
await db.refresh(item)
return ShopItemResponse.model_validate(item)
@router.delete("/admin/items/{item_id}", response_model=MessageResponse)
async def admin_delete_item(
item_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete a shop item (admin only)"""
require_admin_with_2fa(current_user)
item = await shop_service.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
await db.delete(item)
await db.commit()
return MessageResponse(message=f"Item '{item.name}' deleted")
@router.post("/admin/users/{user_id}/coins/grant", response_model=MessageResponse)
async def admin_grant_coins(
user_id: int,
data: AdminCoinsRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Grant coins to a user (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
await coins_service.admin_grant_coins(db, user, data.amount, data.reason, current_user.id)
await db.commit()
return MessageResponse(message=f"Granted {data.amount} coins to {user.nickname}")
@router.post("/admin/users/{user_id}/coins/deduct", response_model=MessageResponse)
async def admin_deduct_coins(
user_id: int,
data: AdminCoinsRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Deduct coins from a user (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
success = await coins_service.admin_deduct_coins(db, user, data.amount, data.reason, current_user.id)
if not success:
raise HTTPException(status_code=400, detail="User doesn't have enough coins")
await db.commit()
return MessageResponse(message=f"Deducted {data.amount} coins from {user.nickname}")
@router.get("/admin/certification/pending", response_model=list[dict])
async def admin_get_pending_certifications(
current_user: CurrentUser,
db: DbSession,
):
"""Get list of marathons pending certification (admin only)"""
require_admin_with_2fa(current_user)
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.creator))
.where(Marathon.certification_status == CertificationStatus.PENDING.value)
.order_by(Marathon.certification_requested_at.asc())
)
marathons = result.scalars().all()
return [
{
"id": m.id,
"title": m.title,
"creator_nickname": m.creator.nickname,
"status": m.status,
"participants_count": len(m.participants) if m.participants else 0,
"certification_requested_at": m.certification_requested_at,
}
for m in marathons
]
@router.post("/admin/certification/{marathon_id}/review", response_model=CertificationStatusResponse)
async def admin_review_certification(
marathon_id: int,
data: CertificationReviewRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Approve or reject marathon certification (admin only)"""
require_admin_with_2fa(current_user)
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.certification_status != CertificationStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Marathon is not pending certification")
if data.approve:
marathon.certification_status = CertificationStatus.CERTIFIED.value
marathon.certified_at = datetime.utcnow()
marathon.certified_by_id = current_user.id
marathon.certification_rejection_reason = None
else:
if not data.rejection_reason:
raise HTTPException(status_code=400, detail="Rejection reason is required")
marathon.certification_status = CertificationStatus.REJECTED.value
marathon.certification_rejection_reason = data.rejection_reason
await db.commit()
await db.refresh(marathon)
return CertificationStatusResponse(
marathon_id=marathon.id,
certification_status=marathon.certification_status,
is_certified=marathon.is_certified,
certification_requested_at=marathon.certification_requested_at,
certified_at=marathon.certified_at,
certified_by_nickname=current_user.nickname if data.approve else None,
rejection_reason=marathon.certification_rejection_reason,
)
# === Admin Item Granting ===
@router.post("/admin/users/{user_id}/items/grant", response_model=MessageResponse)
async def admin_grant_item(
user_id: int,
data: AdminGrantItemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Grant an item to a user (admin only)"""
require_admin_with_2fa(current_user)
# Get target user
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get item
item = await shop_service.get_item_by_id(db, data.item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
# Check if user already has this item in inventory
result = await db.execute(
select(UserInventory).where(
UserInventory.user_id == user_id,
UserInventory.item_id == data.item_id,
)
)
existing = result.scalar_one_or_none()
if existing:
# Add to quantity
existing.quantity += data.quantity
else:
# Create new inventory item
inventory_item = UserInventory(
user_id=user_id,
item_id=data.item_id,
quantity=data.quantity,
)
db.add(inventory_item)
# Log the action (using coin transaction as audit log)
transaction = CoinTransaction(
user_id=user_id,
amount=0,
transaction_type="admin_grant_item",
description=f"Admin granted {item.name} x{data.quantity}: {data.reason}",
reference_type="admin_action",
reference_id=current_user.id,
)
db.add(transaction)
await db.commit()
# Send Telegram notification
await telegram_notifier.notify_item_granted(
user=user,
item_name=item.name,
quantity=data.quantity,
reason=data.reason,
admin_nickname=current_user.nickname,
)
return MessageResponse(message=f"Granted {item.name} x{data.quantity} to {user.nickname}")
@router.get("/admin/users/{user_id}/inventory", response_model=list[InventoryItemResponse])
async def admin_get_user_inventory(
user_id: int,
current_user: CurrentUser,
db: DbSession,
item_type: str | None = None,
):
"""Get a user's inventory (admin only)"""
require_admin_with_2fa(current_user)
# Check user exists
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
inventory = await shop_service.get_user_inventory(db, user_id, item_type)
return [InventoryItemResponse.model_validate(inv) for inv in inventory]
@router.delete("/admin/users/{user_id}/inventory/{inventory_id}", response_model=MessageResponse)
async def admin_remove_inventory_item(
user_id: int,
inventory_id: int,
current_user: CurrentUser,
db: DbSession,
quantity: int = 1,
):
"""Remove an item from user's inventory (admin only)"""
require_admin_with_2fa(current_user)
# Check user exists
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get inventory item
result = await db.execute(
select(UserInventory)
.options(selectinload(UserInventory.item))
.where(
UserInventory.id == inventory_id,
UserInventory.user_id == user_id,
)
)
inv = result.scalar_one_or_none()
if not inv:
raise HTTPException(status_code=404, detail="Inventory item not found")
item_name = inv.item.name
if quantity >= inv.quantity:
# Remove entirely
await db.delete(inv)
removed_qty = inv.quantity
else:
# Reduce quantity
inv.quantity -= quantity
removed_qty = quantity
# Log the action
transaction = CoinTransaction(
user_id=user_id,
amount=0,
transaction_type="admin_remove_item",
description=f"Admin removed {item_name} x{removed_qty}",
reference_type="admin_action",
reference_id=current_user.id,
)
db.add(transaction)
await db.commit()
return MessageResponse(message=f"Removed {item_name} x{removed_qty} from {user.nickname}")

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.config import settings from app.core.config import settings
@@ -20,7 +21,16 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=UserPublic) @router.get("/{user_id}", response_model=UserPublic)
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser): async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
"""Get user profile. Requires authentication.""" """Get user profile. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(
select(User)
.where(User.id == user_id)
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
@@ -239,7 +249,16 @@ async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser)
@router.get("/{user_id}/profile", response_model=UserProfilePublic) @router.get("/{user_id}/profile", response_model=UserProfilePublic)
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser): async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
"""Получить публичный профиль пользователя со статистикой. Requires authentication.""" """Получить публичный профиль пользователя со статистикой. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(
select(User)
.where(User.id == user_id)
.options(
selectinload(User.equipped_frame),
selectinload(User.equipped_title),
selectinload(User.equipped_name_color),
selectinload(User.equipped_background),
)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
@@ -254,8 +273,14 @@ async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUse
id=user.id, id=user.id,
nickname=user.nickname, nickname=user.nickname,
avatar_url=user.avatar_url, avatar_url=user.avatar_url,
telegram_avatar_url=user.telegram_avatar_url,
role=user.role,
created_at=user.created_at, created_at=user.created_at,
stats=stats, stats=stats,
equipped_frame=user.equipped_frame,
equipped_title=user.equipped_title,
equipped_name_color=user.equipped_name_color,
equipped_background=user.equipped_background,
) )

View File

@@ -15,11 +15,14 @@ from app.models import (
from app.schemas import ( from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult, SpinResult, AssignmentResponse, CompleteResult, DropResult,
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse, GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
TrackTimeRequest,
) )
from app.schemas.game import PlaythroughInfo from app.schemas.game import PlaythroughInfo
from app.services.points import PointsService from app.services.points import PointsService
from app.services.events import event_service from app.services.events import event_service
from app.services.storage import storage_service from app.services.storage import storage_service
from app.services.coins import coins_service
from app.services.consumables import consumables_service
from app.api.v1.games import get_available_games_for_participant from app.api.v1.games import get_available_games_for_participant
router = APIRouter(tags=["wheel"]) router = APIRouter(tags=["wheel"])
@@ -439,6 +442,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
drop_penalty=drop_penalty, drop_penalty=drop_penalty,
bonus_challenges=bonus_responses, bonus_challenges=bonus_responses,
event_type=assignment.event_type, event_type=assignment.event_type,
tracked_time_minutes=assignment.tracked_time_minutes,
) )
# Regular challenge assignment # Regular challenge assignment
@@ -474,6 +478,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
completed_at=assignment.completed_at, completed_at=assignment.completed_at,
drop_penalty=drop_penalty, drop_penalty=drop_penalty,
event_type=assignment.event_type, event_type=assignment.event_type,
tracked_time_minutes=assignment.tracked_time_minutes,
) )
@@ -587,6 +592,13 @@ async def complete_assignment(
if assignment.is_playthrough: if assignment.is_playthrough:
game = assignment.game game = assignment.game
marathon_id = game.marathon_id marathon_id = game.marathon_id
# If tracked time exists (from desktop app), calculate points as hours * 30
# Otherwise use admin-set playthrough_points
if assignment.tracked_time_minutes > 0:
hours = assignment.tracked_time_minutes / 60
base_playthrough_points = int(hours * 30)
else:
base_playthrough_points = game.playthrough_points base_playthrough_points = game.playthrough_points
# Calculate BASE bonus points from completed bonus assignments (before multiplier) # Calculate BASE bonus points from completed bonus assignments (before multiplier)
@@ -619,6 +631,13 @@ async def complete_assignment(
if ba.status == BonusAssignmentStatus.COMPLETED.value: if ba.status == BonusAssignmentStatus.COMPLETED.value:
ba.points_earned = int(ba.challenge.points * multiplier) ba.points_earned = int(ba.challenge.points * multiplier)
# Apply boost and lucky dice multipliers from consumables
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
combined_multiplier = boost_multiplier * lucky_dice_multiplier
if combined_multiplier != 1.0:
total_points = int(total_points * combined_multiplier)
# Update assignment # Update assignment
assignment.status = AssignmentStatus.COMPLETED.value assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points assignment.points_earned = total_points
@@ -630,6 +649,15 @@ async def complete_assignment(
participant.current_streak += 1 participant.current_streak += 1
participant.drop_count = 0 participant.drop_count = 0
# Get marathon and award coins if certified
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = marathon_result.scalar_one()
coins_earned = 0
if marathon.is_certified:
coins_earned = await coins_service.award_playthrough_coins(
db, current_user, participant, marathon, total_points, assignment.id
)
# Check if this is a redo of a previously disputed assignment # Check if this is a redo of a previously disputed assignment
is_redo = ( is_redo = (
assignment.dispute is not None and assignment.dispute is not None and
@@ -648,6 +676,12 @@ async def complete_assignment(
} }
if is_redo: if is_redo:
activity_data["is_redo"] = True activity_data["is_redo"] = True
if boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if lucky_dice_multiplier != 1.0:
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if playthrough_event: if playthrough_event:
activity_data["event_type"] = playthrough_event.type activity_data["event_type"] = playthrough_event.type
activity_data["event_bonus"] = event_bonus activity_data["event_bonus"] = event_bonus
@@ -673,6 +707,7 @@ async def complete_assignment(
streak_bonus=streak_bonus, streak_bonus=streak_bonus,
total_points=participant.total_points, total_points=participant.total_points,
new_streak=participant.current_streak, new_streak=participant.current_streak,
coins_earned=coins_earned,
) )
# Regular challenge completion # Regular challenge completion
@@ -707,6 +742,13 @@ async def complete_assignment(
total_points += common_enemy_bonus total_points += common_enemy_bonus
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}") print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Apply boost and lucky dice multipliers from consumables
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
combined_multiplier = boost_multiplier * lucky_dice_multiplier
if combined_multiplier != 1.0:
total_points = int(total_points * combined_multiplier)
# Update assignment # Update assignment
assignment.status = AssignmentStatus.COMPLETED.value assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points assignment.points_earned = total_points
@@ -718,6 +760,15 @@ async def complete_assignment(
participant.current_streak += 1 participant.current_streak += 1
participant.drop_count = 0 participant.drop_count = 0
# Get marathon and award coins if certified
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = marathon_result.scalar_one()
coins_earned = 0
if marathon.is_certified:
coins_earned = await coins_service.award_challenge_coins(
db, current_user, participant, marathon, challenge.difficulty, assignment.id
)
# Check if this is a redo of a previously disputed assignment # Check if this is a redo of a previously disputed assignment
is_redo = ( is_redo = (
assignment.dispute is not None and assignment.dispute is not None and
@@ -735,6 +786,12 @@ async def complete_assignment(
} }
if is_redo: if is_redo:
activity_data["is_redo"] = True activity_data["is_redo"] = True
if boost_multiplier > 1.0:
activity_data["boost_multiplier"] = boost_multiplier
if lucky_dice_multiplier != 1.0:
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if assignment.event_type == EventType.JACKPOT.value: if assignment.event_type == EventType.JACKPOT.value:
activity_data["event_type"] = assignment.event_type activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus activity_data["event_bonus"] = event_bonus
@@ -799,9 +856,41 @@ async def complete_assignment(
streak_bonus=streak_bonus, streak_bonus=streak_bonus,
total_points=participant.total_points, total_points=participant.total_points,
new_streak=participant.current_streak, new_streak=participant.current_streak,
coins_earned=coins_earned,
) )
@router.patch("/assignments/{assignment_id}/track-time", response_model=MessageResponse)
async def track_assignment_time(
assignment_id: int,
data: TrackTimeRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Update tracked time for an assignment (from desktop app)"""
result = await db.execute(
select(Assignment)
.options(selectinload(Assignment.participant))
.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")
# Update tracked time (replace with new value)
assignment.tracked_time_minutes = max(0, data.minutes)
await db.commit()
return MessageResponse(message=f"Tracked time updated to {data.minutes} minutes")
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult) @router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession): async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
"""Drop current assignment""" """Drop current assignment"""
@@ -847,6 +936,11 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
participant.drop_count, game.playthrough_points, playthrough_event participant.drop_count, game.playthrough_points, playthrough_event
) )
# Save drop data for potential undo
consumables_service.save_drop_for_undo(
participant, penalty, participant.current_streak
)
# Update assignment # Update assignment
assignment.status = AssignmentStatus.DROPPED.value assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow() assignment.completed_at = datetime.utcnow()
@@ -904,6 +998,11 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
# Calculate penalty (0 if double_risk event is active) # Calculate penalty (0 if double_risk event is active)
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event) penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
# Save drop data for potential undo
consumables_service.save_drop_for_undo(
participant, penalty, participant.current_streak
)
# Update assignment # Update assignment
assignment.status = AssignmentStatus.DROPPED.value assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow() assignment.completed_at = datetime.utcnow()
@@ -1018,6 +1117,7 @@ async def get_my_history(
started_at=a.started_at, started_at=a.started_at,
completed_at=a.completed_at, completed_at=a.completed_at,
bonus_challenges=bonus_responses, bonus_challenges=bonus_responses,
tracked_time_minutes=a.tracked_time_minutes,
)) ))
else: else:
# Regular challenge assignment # Regular challenge assignment
@@ -1050,6 +1150,7 @@ async def get_my_history(
streak_at_completion=a.streak_at_completion, streak_at_completion=a.streak_at_completion,
started_at=a.started_at, started_at=a.started_at,
completed_at=a.completed_at, completed_at=a.completed_at,
tracked_time_minutes=a.tracked_time_minutes,
)) ))
return responses return responses

View File

@@ -0,0 +1,423 @@
import secrets
from datetime import datetime
from fastapi import APIRouter, HTTPException, status, Query
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser, require_participant
from app.models import (
WidgetToken, Participant, Marathon, Assignment, AssignmentStatus,
BonusAssignment, BonusAssignmentStatus,
)
from app.schemas.widget import (
WidgetTokenResponse,
WidgetTokenListItem,
WidgetLeaderboardEntry,
WidgetLeaderboardResponse,
WidgetCurrentResponse,
WidgetProgressResponse,
)
from app.schemas.common import MessageResponse
from app.core.config import settings
router = APIRouter(prefix="/widgets", tags=["widgets"])
def get_avatar_url(user) -> str | None:
"""Get avatar URL - through backend API if user has avatar, else telegram"""
if user.avatar_path:
return f"/api/v1/users/{user.id}/avatar"
return user.telegram_avatar_url
def generate_widget_token() -> str:
"""Generate a secure widget token"""
return f"wgt_{secrets.token_urlsafe(32)}"
def build_widget_urls(marathon_id: int, token: str) -> dict[str, str]:
"""Build widget URLs for the token"""
base_url = settings.FRONTEND_URL or "http://localhost:5173"
params = f"marathon={marathon_id}&token={token}"
return {
"leaderboard": f"{base_url}/widget/leaderboard?{params}",
"current": f"{base_url}/widget/current?{params}",
"progress": f"{base_url}/widget/progress?{params}",
}
# === Token management (authenticated) ===
@router.post("/marathons/{marathon_id}/token", response_model=WidgetTokenResponse)
async def create_widget_token(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Create a widget token for the current user in a marathon"""
participant = await require_participant(db, current_user.id, marathon_id)
# Check if user already has an active token
existing = await db.scalar(
select(WidgetToken).where(
WidgetToken.participant_id == participant.id,
WidgetToken.marathon_id == marathon_id,
WidgetToken.is_active == True,
)
)
if existing:
# Return existing token
return WidgetTokenResponse(
id=existing.id,
token=existing.token,
created_at=existing.created_at,
expires_at=existing.expires_at,
is_active=existing.is_active,
urls=build_widget_urls(marathon_id, existing.token),
)
# Create new token
token = generate_widget_token()
widget_token = WidgetToken(
token=token,
participant_id=participant.id,
marathon_id=marathon_id,
)
db.add(widget_token)
await db.commit()
await db.refresh(widget_token)
return WidgetTokenResponse(
id=widget_token.id,
token=widget_token.token,
created_at=widget_token.created_at,
expires_at=widget_token.expires_at,
is_active=widget_token.is_active,
urls=build_widget_urls(marathon_id, widget_token.token),
)
@router.get("/marathons/{marathon_id}/tokens", response_model=list[WidgetTokenListItem])
async def list_widget_tokens(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""List all widget tokens for the current user in a marathon"""
participant = await require_participant(db, current_user.id, marathon_id)
result = await db.execute(
select(WidgetToken)
.where(
WidgetToken.participant_id == participant.id,
WidgetToken.marathon_id == marathon_id,
)
.order_by(WidgetToken.created_at.desc())
)
tokens = result.scalars().all()
return [
WidgetTokenListItem(
id=t.id,
token=t.token,
created_at=t.created_at,
is_active=t.is_active,
)
for t in tokens
]
@router.delete("/tokens/{token_id}", response_model=MessageResponse)
async def revoke_widget_token(
token_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Revoke a widget token"""
result = await db.execute(
select(WidgetToken)
.options(selectinload(WidgetToken.participant))
.where(WidgetToken.id == token_id)
)
widget_token = result.scalar_one_or_none()
if not widget_token:
raise HTTPException(status_code=404, detail="Token not found")
if widget_token.participant.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not authorized to revoke this token")
widget_token.is_active = False
await db.commit()
return MessageResponse(message="Token revoked")
@router.post("/tokens/{token_id}/regenerate", response_model=WidgetTokenResponse)
async def regenerate_widget_token(
token_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Regenerate a widget token (deactivates old, creates new)"""
result = await db.execute(
select(WidgetToken)
.options(selectinload(WidgetToken.participant))
.where(WidgetToken.id == token_id)
)
old_token = result.scalar_one_or_none()
if not old_token:
raise HTTPException(status_code=404, detail="Token not found")
if old_token.participant.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not authorized")
# Deactivate old token
old_token.is_active = False
# Create new token
new_token = WidgetToken(
token=generate_widget_token(),
participant_id=old_token.participant_id,
marathon_id=old_token.marathon_id,
)
db.add(new_token)
await db.commit()
await db.refresh(new_token)
return WidgetTokenResponse(
id=new_token.id,
token=new_token.token,
created_at=new_token.created_at,
expires_at=new_token.expires_at,
is_active=new_token.is_active,
urls=build_widget_urls(new_token.marathon_id, new_token.token),
)
# === Public widget endpoints (authenticated via widget token) ===
async def validate_widget_token(token: str, marathon_id: int, db) -> WidgetToken:
"""Validate widget token and return it"""
result = await db.execute(
select(WidgetToken)
.options(
selectinload(WidgetToken.participant).selectinload(Participant.user),
selectinload(WidgetToken.marathon),
)
.where(
WidgetToken.token == token,
WidgetToken.marathon_id == marathon_id,
WidgetToken.is_active == True,
)
)
widget_token = result.scalar_one_or_none()
if not widget_token:
raise HTTPException(status_code=401, detail="Invalid widget token")
if widget_token.expires_at and widget_token.expires_at < datetime.utcnow():
raise HTTPException(status_code=401, detail="Widget token expired")
return widget_token
@router.get("/data/leaderboard", response_model=WidgetLeaderboardResponse)
async def widget_leaderboard(
marathon: int = Query(..., description="Marathon ID"),
token: str = Query(..., description="Widget token"),
count: int = Query(5, ge=1, le=50, description="Number of participants"),
db: DbSession = None,
):
"""Get leaderboard data for widget"""
widget_token = await validate_widget_token(token, marathon, db)
current_participant = widget_token.participant
# Get all participants ordered by points
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon)
.order_by(Participant.total_points.desc())
)
all_participants = result.scalars().all()
total_participants = len(all_participants)
current_user_rank = None
# Find current user rank and build entries
entries = []
for rank, p in enumerate(all_participants, 1):
if p.id == current_participant.id:
current_user_rank = rank
if rank <= count:
user = p.user
entries.append(WidgetLeaderboardEntry(
rank=rank,
nickname=user.nickname,
avatar_url=get_avatar_url(user),
total_points=p.total_points,
current_streak=p.current_streak,
is_current_user=(p.id == current_participant.id),
))
return WidgetLeaderboardResponse(
entries=entries,
current_user_rank=current_user_rank,
total_participants=total_participants,
marathon_title=widget_token.marathon.title,
)
@router.get("/data/current", response_model=WidgetCurrentResponse)
async def widget_current_assignment(
marathon: int = Query(..., description="Marathon ID"),
token: str = Query(..., description="Widget token"),
db: DbSession = None,
):
"""Get current assignment data for widget"""
widget_token = await validate_widget_token(token, marathon, db)
participant = widget_token.participant
# Get active assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge),
selectinload(Assignment.game),
)
.where(
Assignment.participant_id == participant.id,
Assignment.status.in_([
AssignmentStatus.ACTIVE.value,
AssignmentStatus.RETURNED.value,
]),
)
.order_by(Assignment.started_at.desc())
.limit(1)
)
assignment = result.scalar_one_or_none()
if not assignment:
return WidgetCurrentResponse(has_assignment=False)
# Determine assignment type and details
if assignment.is_playthrough:
game = assignment.game
assignment_type = "playthrough"
challenge_title = "Прохождение"
challenge_description = game.playthrough_description
points = game.playthrough_points
difficulty = None
# Count bonus challenges
bonus_result = await db.execute(
select(func.count()).select_from(BonusAssignment)
.where(BonusAssignment.main_assignment_id == assignment.id)
)
bonus_total = bonus_result.scalar() or 0
completed_result = await db.execute(
select(func.count()).select_from(BonusAssignment)
.where(
BonusAssignment.main_assignment_id == assignment.id,
BonusAssignment.status == BonusAssignmentStatus.COMPLETED.value,
)
)
bonus_completed = completed_result.scalar() or 0
game_title = game.title
game_cover_url = f"/api/v1/games/{game.id}/cover" if game.cover_path else None
else:
challenge = assignment.challenge
assignment_type = "challenge"
challenge_title = challenge.title
challenge_description = challenge.description
points = challenge.points
difficulty = challenge.difficulty
bonus_completed = None
bonus_total = None
game = challenge.game if hasattr(challenge, 'game') else None
if not game:
# Load game via challenge
from app.models import Game
game_result = await db.execute(
select(Game).where(Game.id == challenge.game_id)
)
game = game_result.scalar_one_or_none()
game_title = game.title if game else None
game_cover_url = f"/api/v1/games/{game.id}/cover" if game and game.cover_path else None
return WidgetCurrentResponse(
has_assignment=True,
game_title=game_title,
game_cover_url=game_cover_url,
assignment_type=assignment_type,
challenge_title=challenge_title,
challenge_description=challenge_description,
points=points,
difficulty=difficulty,
bonus_completed=bonus_completed,
bonus_total=bonus_total,
)
@router.get("/data/progress", response_model=WidgetProgressResponse)
async def widget_progress(
marathon: int = Query(..., description="Marathon ID"),
token: str = Query(..., description="Widget token"),
db: DbSession = None,
):
"""Get participant progress data for widget"""
widget_token = await validate_widget_token(token, marathon, db)
participant = widget_token.participant
user = participant.user
# Calculate rank
result = await db.execute(
select(func.count())
.select_from(Participant)
.where(
Participant.marathon_id == marathon,
Participant.total_points > participant.total_points,
)
)
higher_count = result.scalar() or 0
rank = higher_count + 1
# Count completed and dropped assignments
completed_result = await db.execute(
select(func.count())
.select_from(Assignment)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.COMPLETED.value,
)
)
completed_count = completed_result.scalar() or 0
dropped_result = await db.execute(
select(func.count())
.select_from(Assignment)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.DROPPED.value,
)
)
dropped_count = dropped_result.scalar() or 0
return WidgetProgressResponse(
nickname=user.nickname,
avatar_url=get_avatar_url(user),
rank=rank,
total_points=participant.total_points,
current_streak=participant.current_streak,
completed_count=completed_count,
dropped_count=dropped_count,
marathon_title=widget_token.marathon.title,
)

View File

@@ -60,7 +60,12 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173", # Desktop app dev
"http://127.0.0.1:5173",
],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@@ -1,5 +1,5 @@
from app.models.user import User, UserRole from app.models.user import User, UserRole
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode from app.models.marathon import Marathon, MarathonStatus, GameProposalMode, CertificationStatus
from app.models.participant import Participant, ParticipantRole from app.models.participant import Participant, ParticipantRole
from app.models.game import Game, GameStatus, GameType from app.models.game import Game, GameStatus, GameType
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
@@ -13,6 +13,13 @@ from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVo
from app.models.admin_log import AdminLog, AdminActionType from app.models.admin_log import AdminLog, AdminActionType
from app.models.admin_2fa import Admin2FASession from app.models.admin_2fa import Admin2FASession
from app.models.static_content import StaticContent from app.models.static_content import StaticContent
from app.models.shop import ShopItem, ShopItemType, ItemRarity, ConsumableType
from app.models.inventory import UserInventory
from app.models.coin_transaction import CoinTransaction, CoinTransactionType
from app.models.consumable_usage import ConsumableUsage
from app.models.promo_code import PromoCode, PromoCodeRedemption
from app.models.widget_token import WidgetToken
from app.models.exiled_game import ExiledGame
__all__ = [ __all__ = [
"User", "User",
@@ -20,6 +27,7 @@ __all__ = [
"Marathon", "Marathon",
"MarathonStatus", "MarathonStatus",
"GameProposalMode", "GameProposalMode",
"CertificationStatus",
"Participant", "Participant",
"ParticipantRole", "ParticipantRole",
"Game", "Game",
@@ -49,4 +57,16 @@ __all__ = [
"AdminActionType", "AdminActionType",
"Admin2FASession", "Admin2FASession",
"StaticContent", "StaticContent",
"ShopItem",
"ShopItemType",
"ItemRarity",
"ConsumableType",
"UserInventory",
"CoinTransaction",
"CoinTransactionType",
"ConsumableUsage",
"PromoCode",
"PromoCodeRedemption",
"WidgetToken",
"ExiledGame",
] ]

View File

@@ -20,6 +20,7 @@ class ActivityType(str, Enum):
EVENT_END = "event_end" EVENT_END = "event_end"
SWAP = "swap" SWAP = "swap"
GAME_CHOICE = "game_choice" GAME_CHOICE = "game_choice"
MODERATION = "moderation"
class Activity(Base): class Activity(Base):

View File

@@ -17,6 +17,8 @@ class AdminActionType(str, Enum):
# Marathon actions # Marathon actions
MARATHON_FORCE_FINISH = "marathon_force_finish" MARATHON_FORCE_FINISH = "marathon_force_finish"
MARATHON_DELETE = "marathon_delete" MARATHON_DELETE = "marathon_delete"
MARATHON_CERTIFY = "marathon_certify"
MARATHON_REVOKE_CERTIFICATION = "marathon_revoke_certification"
# Content actions # Content actions
CONTENT_UPDATE = "content_update" CONTENT_UPDATE = "content_update"

View File

@@ -32,6 +32,7 @@ class Assignment(Base):
proof_comment: 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) points_earned: Mapped[int] = mapped_column(Integer, default=0)
streak_at_completion: Mapped[int | None] = mapped_column(Integer, nullable=True) streak_at_completion: Mapped[int | None] = mapped_column(Integer, nullable=True)
tracked_time_minutes: Mapped[int] = mapped_column(Integer, default=0) # Time tracked by desktop app
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View File

@@ -0,0 +1,42 @@
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 typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class CoinTransactionType(str, Enum):
CHALLENGE_COMPLETE = "challenge_complete"
PLAYTHROUGH_COMPLETE = "playthrough_complete"
MARATHON_WIN = "marathon_win"
MARATHON_PLACE = "marathon_place"
COMMON_ENEMY_BONUS = "common_enemy_bonus"
PURCHASE = "purchase"
REFUND = "refund"
ADMIN_GRANT = "admin_grant"
ADMIN_DEDUCT = "admin_deduct"
PROMO_CODE = "promo_code"
class CoinTransaction(Base):
__tablename__ = "coin_transactions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
amount: Mapped[int] = mapped_column(Integer, nullable=False)
transaction_type: Mapped[str] = mapped_column(String(30), nullable=False)
reference_type: Mapped[str | None] = mapped_column(String(30), nullable=True)
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="coin_transactions"
)

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.shop import ShopItem
from app.models.marathon import Marathon
from app.models.assignment import Assignment
class ConsumableUsage(Base):
__tablename__ = "consumable_usages"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False)
marathon_id: Mapped[int | None] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), nullable=True)
assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True)
used_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
effect_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Relationships
user: Mapped["User"] = relationship("User")
item: Mapped["ShopItem"] = relationship("ShopItem")
marathon: Mapped["Marathon | None"] = relationship("Marathon")
assignment: Mapped["Assignment | None"] = relationship("Assignment")

View File

@@ -0,0 +1,37 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Boolean, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ExiledGame(Base):
"""Изгнанные игры участника - не будут выпадать при спине"""
__tablename__ = "exiled_games"
__table_args__ = (
UniqueConstraint("participant_id", "game_id", name="unique_participant_game_exile"),
)
id: Mapped[int] = mapped_column(primary_key=True)
participant_id: Mapped[int] = mapped_column(
Integer, ForeignKey("participants.id", ondelete="CASCADE"), index=True
)
game_id: Mapped[int] = mapped_column(
Integer, ForeignKey("games.id", ondelete="CASCADE")
)
assignment_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("assignments.id", ondelete="SET NULL"), nullable=True
)
exiled_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
exiled_by: Mapped[str] = mapped_column(String(20)) # "user" | "organizer" | "admin"
reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Soft-delete для истории
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
unexiled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
unexiled_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Relationships
participant: Mapped["Participant"] = relationship("Participant")
game: Mapped["Game"] = relationship("Game")
assignment: Mapped["Assignment"] = relationship("Assignment")

View File

@@ -0,0 +1,39 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.shop import ShopItem
class UserInventory(Base):
__tablename__ = "user_inventory"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
item_id: Mapped[int] = mapped_column(ForeignKey("shop_items.id", ondelete="CASCADE"), nullable=False, index=True)
quantity: Mapped[int] = mapped_column(Integer, default=1)
equipped: Mapped[bool] = mapped_column(Boolean, default=False)
purchased_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="inventory"
)
item: Mapped["ShopItem"] = relationship(
"ShopItem",
back_populates="inventory_items"
)
@property
def is_expired(self) -> bool:
"""Check if item has expired"""
if self.expires_at is None:
return False
return datetime.utcnow() > self.expires_at

View File

@@ -17,6 +17,13 @@ class GameProposalMode(str, Enum):
ORGANIZER_ONLY = "organizer_only" ORGANIZER_ONLY = "organizer_only"
class CertificationStatus(str, Enum):
NONE = "none"
PENDING = "pending"
CERTIFIED = "certified"
REJECTED = "rejected"
class Marathon(Base): class Marathon(Base):
__tablename__ = "marathons" __tablename__ = "marathons"
@@ -35,12 +42,28 @@ class Marathon(Base):
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True) cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Certification fields
certification_status: Mapped[str] = mapped_column(String(20), default=CertificationStatus.NONE.value)
certification_requested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
certified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
certified_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
certification_rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
# Shop/Consumables settings
allow_skips: Mapped[bool] = mapped_column(Boolean, default=True)
max_skips_per_participant: Mapped[int | None] = mapped_column(Integer, nullable=True)
allow_consumables: Mapped[bool] = mapped_column(Boolean, default=True)
# Relationships # Relationships
creator: Mapped["User"] = relationship( creator: Mapped["User"] = relationship(
"User", "User",
back_populates="created_marathons", back_populates="created_marathons",
foreign_keys=[creator_id] foreign_keys=[creator_id]
) )
certified_by: Mapped["User | None"] = relationship(
"User",
foreign_keys=[certified_by_id]
)
participants: Mapped[list["Participant"]] = relationship( participants: Mapped[list["Participant"]] = relationship(
"Participant", "Participant",
back_populates="marathon", back_populates="marathon",
@@ -61,3 +84,7 @@ class Marathon(Base):
back_populates="marathon", back_populates="marathon",
cascade="all, delete-orphan" cascade="all, delete-orphan"
) )
@property
def is_certified(self) -> bool:
return self.certification_status == CertificationStatus.CERTIFIED.value

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -26,6 +26,22 @@ class Participant(Base):
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Shop: coins earned in this marathon
coins_earned: Mapped[int] = mapped_column(Integer, default=0)
# Shop: consumables state
skips_used: Mapped[int] = mapped_column(Integer, default=0)
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
# Lucky Dice state
has_lucky_dice: Mapped[bool] = mapped_column(Boolean, default=False)
lucky_dice_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True)
# Undo state - stores last drop data for potential rollback
last_drop_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_drop_streak_before: Mapped[int | None] = mapped_column(Integer, nullable=True)
can_undo: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships # Relationships
user: Mapped["User"] = relationship("User", back_populates="participations") user: Mapped["User"] = relationship("User", back_populates="participations")
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants") marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")

View File

@@ -0,0 +1,67 @@
"""
Promo Code models for coins distribution
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Boolean, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class PromoCode(Base):
"""Promo code for giving coins to users"""
__tablename__ = "promo_codes"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
coins_amount: Mapped[int] = mapped_column(Integer, nullable=False)
max_uses: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = unlimited
uses_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
valid_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
# Relationships
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_id])
redemptions: Mapped[list["PromoCodeRedemption"]] = relationship(
"PromoCodeRedemption", back_populates="promo_code", cascade="all, delete-orphan"
)
def is_valid(self) -> bool:
"""Check if promo code is currently valid"""
if not self.is_active:
return False
now = datetime.utcnow()
if self.valid_from and now < self.valid_from:
return False
if self.valid_until and now > self.valid_until:
return False
if self.max_uses is not None and self.uses_count >= self.max_uses:
return False
return True
class PromoCodeRedemption(Base):
"""Record of promo code redemption by a user"""
__tablename__ = "promo_code_redemptions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
promo_code_id: Mapped[int] = mapped_column(ForeignKey("promo_codes.id", ondelete="CASCADE"), nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
coins_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
redeemed_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), nullable=False)
__table_args__ = (
UniqueConstraint('promo_code_id', 'user_id', name='uq_promo_code_user'),
)
# Relationships
promo_code: Mapped["PromoCode"] = relationship("PromoCode", back_populates="redemptions")
user: Mapped["User"] = relationship("User")

View File

@@ -0,0 +1,84 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, Integer, Boolean, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base
if TYPE_CHECKING:
from app.models.inventory import UserInventory
class ShopItemType(str, Enum):
FRAME = "frame"
TITLE = "title"
NAME_COLOR = "name_color"
BACKGROUND = "background"
CONSUMABLE = "consumable"
class ItemRarity(str, Enum):
COMMON = "common"
UNCOMMON = "uncommon"
RARE = "rare"
EPIC = "epic"
LEGENDARY = "legendary"
class ConsumableType(str, Enum):
SKIP = "skip"
SKIP_EXILE = "skip_exile" # Скип с изгнанием игры из пула
BOOST = "boost"
WILD_CARD = "wild_card"
LUCKY_DICE = "lucky_dice"
COPYCAT = "copycat"
UNDO = "undo"
class ShopItem(Base):
__tablename__ = "shop_items"
id: Mapped[int] = mapped_column(primary_key=True)
item_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
price: Mapped[int] = mapped_column(Integer, nullable=False)
rarity: Mapped[str] = mapped_column(String(20), default=ItemRarity.COMMON.value)
asset_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
available_from: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
available_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
stock_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
stock_remaining: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
inventory_items: Mapped[list["UserInventory"]] = relationship(
"UserInventory",
back_populates="item"
)
@property
def is_available(self) -> bool:
"""Check if item is currently available for purchase"""
if not self.is_active:
return False
now = datetime.utcnow()
if self.available_from and self.available_from > now:
return False
if self.available_until and self.available_until < now:
return False
if self.stock_remaining is not None and self.stock_remaining <= 0:
return False
return True
@property
def is_consumable(self) -> bool:
return self.item_type == ShopItemType.CONSUMABLE.value

View File

@@ -2,9 +2,15 @@ from datetime import datetime
from enum import Enum from enum import Enum
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import TYPE_CHECKING
from app.core.database import Base from app.core.database import Base
if TYPE_CHECKING:
from app.models.shop import ShopItem
from app.models.inventory import UserInventory
from app.models.coin_transaction import CoinTransaction
class UserRole(str, Enum): class UserRole(str, Enum):
USER = "user" USER = "user"
@@ -39,6 +45,15 @@ class User(Base):
notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True) notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True)
notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True) notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True)
# Shop: coins balance
coins_balance: Mapped[int] = mapped_column(Integer, default=0)
# Shop: equipped cosmetics
equipped_frame_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
equipped_title_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
equipped_name_color_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
equipped_background_id: Mapped[int | None] = mapped_column(ForeignKey("shop_items.id", ondelete="SET NULL"), nullable=True)
# Relationships # Relationships
created_marathons: Mapped[list["Marathon"]] = relationship( created_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon", "Marathon",
@@ -65,6 +80,32 @@ class User(Base):
foreign_keys=[banned_by_id] foreign_keys=[banned_by_id]
) )
# Shop relationships
inventory: Mapped[list["UserInventory"]] = relationship(
"UserInventory",
back_populates="user"
)
coin_transactions: Mapped[list["CoinTransaction"]] = relationship(
"CoinTransaction",
back_populates="user"
)
equipped_frame: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_frame_id]
)
equipped_title: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_title_id]
)
equipped_name_color: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_name_color_id]
)
equipped_background: Mapped["ShopItem | None"] = relationship(
"ShopItem",
foreign_keys=[equipped_background_id]
)
@property @property
def is_admin(self) -> bool: def is_admin(self) -> bool:
return self.role == UserRole.ADMIN.value return self.role == UserRole.ADMIN.value

View File

@@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class WidgetToken(Base):
"""Токен для авторизации OBS виджетов"""
__tablename__ = "widget_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"))
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Relationships
participant: Mapped["Participant"] = relationship("Participant")
marathon: Mapped["Marathon"] = relationship("Marathon")

View File

@@ -23,6 +23,8 @@ from app.schemas.marathon import (
JoinMarathon, JoinMarathon,
LeaderboardEntry, LeaderboardEntry,
SetParticipantRole, SetParticipantRole,
OrganizerSkipRequest,
ExiledGameResponse,
) )
from app.schemas.game import ( from app.schemas.game import (
GameCreate, GameCreate,
@@ -52,6 +54,7 @@ from app.schemas.assignment import (
CompleteBonusAssignment, CompleteBonusAssignment,
BonusCompleteResult, BonusCompleteResult,
AvailableGamesCount, AvailableGamesCount,
TrackTimeRequest,
) )
from app.schemas.activity import ( from app.schemas.activity import (
ActivityResponse, ActivityResponse,
@@ -104,6 +107,46 @@ from app.schemas.admin import (
LoginResponse, LoginResponse,
DashboardStats, DashboardStats,
) )
from app.schemas.shop import (
ShopItemCreate,
ShopItemUpdate,
ShopItemResponse,
InventoryItemResponse,
PurchaseRequest,
PurchaseResponse,
UseConsumableRequest,
UseConsumableResponse,
EquipItemRequest,
EquipItemResponse,
CoinTransactionResponse,
CoinsBalanceResponse,
AdminCoinsRequest,
UserCosmeticsResponse,
CertificationRequestSchema,
CertificationReviewRequest,
CertificationStatusResponse,
ConsumablesStatusResponse,
AdminGrantItemRequest,
)
from app.schemas.promo_code import (
PromoCodeCreate,
PromoCodeUpdate,
PromoCodeResponse,
PromoCodeRedeemRequest,
PromoCodeRedeemResponse,
PromoCodeRedemptionResponse,
PromoCodeRedemptionUser,
)
from app.schemas.user import ShopItemPublic
from app.schemas.widget import (
WidgetTokenCreate,
WidgetTokenResponse,
WidgetTokenListItem,
WidgetLeaderboardEntry,
WidgetLeaderboardResponse,
WidgetCurrentResponse,
WidgetProgressResponse,
)
__all__ = [ __all__ = [
# User # User
@@ -130,6 +173,8 @@ __all__ = [
"JoinMarathon", "JoinMarathon",
"LeaderboardEntry", "LeaderboardEntry",
"SetParticipantRole", "SetParticipantRole",
"OrganizerSkipRequest",
"ExiledGameResponse",
# Game # Game
"GameCreate", "GameCreate",
"GameUpdate", "GameUpdate",
@@ -202,4 +247,41 @@ __all__ = [
"TwoFactorVerifyRequest", "TwoFactorVerifyRequest",
"LoginResponse", "LoginResponse",
"DashboardStats", "DashboardStats",
# Shop
"ShopItemCreate",
"ShopItemUpdate",
"ShopItemResponse",
"ShopItemPublic",
"InventoryItemResponse",
"PurchaseRequest",
"PurchaseResponse",
"UseConsumableRequest",
"UseConsumableResponse",
"EquipItemRequest",
"EquipItemResponse",
"CoinTransactionResponse",
"CoinsBalanceResponse",
"AdminCoinsRequest",
"UserCosmeticsResponse",
"CertificationRequestSchema",
"CertificationReviewRequest",
"CertificationStatusResponse",
"ConsumablesStatusResponse",
"AdminGrantItemRequest",
# Promo
"PromoCodeCreate",
"PromoCodeUpdate",
"PromoCodeResponse",
"PromoCodeRedeemRequest",
"PromoCodeRedeemResponse",
"PromoCodeRedemptionResponse",
"PromoCodeRedemptionUser",
# Widget
"WidgetTokenCreate",
"WidgetTokenResponse",
"WidgetTokenListItem",
"WidgetLeaderboardEntry",
"WidgetLeaderboardResponse",
"WidgetCurrentResponse",
"WidgetProgressResponse",
] ]

View File

@@ -52,6 +52,7 @@ class AssignmentResponse(BaseModel):
proof_comment: str | None = None proof_comment: str | None = None
points_earned: int points_earned: int
streak_at_completion: int | None = None streak_at_completion: int | None = None
tracked_time_minutes: int = 0 # Time tracked by desktop app
started_at: datetime started_at: datetime
completed_at: datetime | None = None completed_at: datetime | None = None
drop_penalty: int = 0 # Calculated penalty if dropped drop_penalty: int = 0 # Calculated penalty if dropped
@@ -62,6 +63,11 @@ class AssignmentResponse(BaseModel):
from_attributes = True from_attributes = True
class TrackTimeRequest(BaseModel):
"""Request to update tracked time for an assignment"""
minutes: int # Total minutes tracked (replaces previous value)
class SpinResult(BaseModel): class SpinResult(BaseModel):
assignment_id: int assignment_id: int
game: GameResponse game: GameResponse
@@ -79,6 +85,7 @@ class CompleteResult(BaseModel):
streak_bonus: int streak_bonus: int
total_points: int total_points: int
new_streak: int new_streak: int
coins_earned: int = 0 # Coins earned (only in certified marathons)
class DropResult(BaseModel): class DropResult(BaseModel):

View File

@@ -19,7 +19,7 @@ class ChallengeBase(BaseModel):
description: str = Field(..., min_length=1) description: str = Field(..., min_length=1)
type: ChallengeType type: ChallengeType
difficulty: Difficulty difficulty: Difficulty
points: int = Field(..., ge=1, le=1000) points: int = Field(..., ge=1)
estimated_time: int | None = Field(None, ge=1) # minutes estimated_time: int | None = Field(None, ge=1) # minutes
proof_type: ProofType proof_type: ProofType
proof_hint: str | None = None proof_hint: str | None = None
@@ -34,7 +34,7 @@ class ChallengeUpdate(BaseModel):
description: str | None = None description: str | None = None
type: ChallengeType | None = None type: ChallengeType | None = None
difficulty: Difficulty | None = None difficulty: Difficulty | None = None
points: int | None = Field(None, ge=1, le=1000) points: int | None = Field(None, ge=1)
estimated_time: int | None = None estimated_time: int | None = None
proof_type: ProofType | None = None proof_type: ProofType | None = None
proof_hint: str | None = None proof_hint: str | None = None

View File

@@ -128,10 +128,16 @@ class SwapCandidate(BaseModel):
"""Participant available for assignment swap""" """Participant available for assignment swap"""
participant_id: int participant_id: int
user: UserPublic user: UserPublic
challenge_title: str is_playthrough: bool = False
challenge_description: str # Challenge fields (used when is_playthrough=False)
challenge_points: int challenge_title: str | None = None
challenge_difficulty: str challenge_description: str | None = None
challenge_points: int | None = None
challenge_difficulty: str | None = None
# Playthrough fields (used when is_playthrough=True)
playthrough_description: str | None = None
playthrough_points: int | None = None
# Common field
game_title: str game_title: str
@@ -145,11 +151,17 @@ class SwapRequestCreate(BaseModel):
class SwapRequestChallengeInfo(BaseModel): class SwapRequestChallengeInfo(BaseModel):
"""Challenge info for swap request display""" """Challenge or playthrough info for swap request display"""
title: str is_playthrough: bool = False
description: str # Challenge fields (used when is_playthrough=False)
points: int title: str | None = None
difficulty: str description: str | None = None
points: int | None = None
difficulty: str | None = None
# Playthrough fields (used when is_playthrough=True)
playthrough_description: str | None = None
playthrough_points: int | None = None
# Common field
game_title: str game_title: str

View File

@@ -20,7 +20,7 @@ class GameCreate(GameBase):
game_type: GameType = GameType.CHALLENGES game_type: GameType = GameType.CHALLENGES
# Поля для типа "Прохождение" # Поля для типа "Прохождение"
playthrough_points: int | None = Field(None, ge=1, le=1000) playthrough_points: int | None = Field(None, ge=1)
playthrough_description: str | None = None playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None playthrough_proof_hint: str | None = None
@@ -46,7 +46,7 @@ class GameUpdate(BaseModel):
game_type: GameType | None = None game_type: GameType | None = None
# Поля для типа "Прохождение" # Поля для типа "Прохождение"
playthrough_points: int | None = Field(None, ge=1, le=1000) playthrough_points: int | None = Field(None, ge=1)
playthrough_description: str | None = None playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None playthrough_proof_hint: str | None = None
@@ -87,7 +87,7 @@ class GameResponse(GameBase):
class PlaythroughInfo(BaseModel): class PlaythroughInfo(BaseModel):
"""Информация о прохождении для игр типа playthrough""" """Информация о прохождении для игр типа playthrough"""
description: str description: str | None = None
points: int points: int | None = None
proof_type: str proof_type: str | None = None
proof_hint: str | None = None proof_hint: str | None = None

View File

@@ -14,6 +14,10 @@ class MarathonCreate(MarathonBase):
duration_days: int = Field(default=30, ge=1, le=365) duration_days: int = Field(default=30, ge=1, le=365)
is_public: bool = False is_public: bool = False
game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$") game_proposal_mode: str = Field(default="all_participants", pattern="^(all_participants|organizer_only)$")
# Shop/Consumables settings
allow_skips: bool = True
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
allow_consumables: bool = True
class MarathonUpdate(BaseModel): class MarathonUpdate(BaseModel):
@@ -23,6 +27,10 @@ class MarathonUpdate(BaseModel):
is_public: bool | None = None is_public: bool | None = None
game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$") game_proposal_mode: str | None = Field(None, pattern="^(all_participants|organizer_only)$")
auto_events_enabled: bool | None = None auto_events_enabled: bool | None = None
# Shop/Consumables settings
allow_skips: bool | None = None
max_skips_per_participant: int | None = Field(None, ge=1, le=100)
allow_consumables: bool | None = None
class ParticipantInfo(BaseModel): class ParticipantInfo(BaseModel):
@@ -32,6 +40,13 @@ class ParticipantInfo(BaseModel):
current_streak: int current_streak: int
drop_count: int drop_count: int
joined_at: datetime joined_at: datetime
# Shop: coins and consumables status
coins_earned: int = 0
skips_used: int = 0
has_active_boost: bool = False
has_lucky_dice: bool = False
lucky_dice_multiplier: float | None = None
can_undo: bool = False
class Config: class Config:
from_attributes = True from_attributes = True
@@ -56,6 +71,13 @@ class MarathonResponse(MarathonBase):
games_count: int games_count: int
created_at: datetime created_at: datetime
my_participation: ParticipantInfo | None = None my_participation: ParticipantInfo | None = None
# Certification
certification_status: str = "none"
is_certified: bool = False
# Shop/Consumables settings
allow_skips: bool = True
max_skips_per_participant: int | None = None
allow_consumables: bool = True
class Config: class Config:
from_attributes = True from_attributes = True
@@ -74,6 +96,8 @@ class MarathonListItem(BaseModel):
participants_count: int participants_count: int
start_date: datetime | None start_date: datetime | None
end_date: datetime | None end_date: datetime | None
# Certification badge
is_certified: bool = False
class Config: class Config:
from_attributes = True from_attributes = True
@@ -104,3 +128,23 @@ class LeaderboardEntry(BaseModel):
current_streak: int current_streak: int
completed_count: int completed_count: int
dropped_count: int dropped_count: int
# Moderation schemas
class OrganizerSkipRequest(BaseModel):
"""Request to skip a participant's assignment by organizer"""
exile: bool = False # If true, also exile the game from participant's pool
reason: str | None = None
class ExiledGameResponse(BaseModel):
"""Exiled game info"""
id: int
game_id: int
game_title: str
exiled_at: datetime
exiled_by: str # "user" | "organizer" | "admin"
reason: str | None
class Config:
from_attributes = True

View File

@@ -0,0 +1,74 @@
"""
Promo Code schemas
"""
from datetime import datetime
from pydantic import BaseModel, Field
# === Create/Update ===
class PromoCodeCreate(BaseModel):
"""Schema for creating a promo code"""
code: str | None = Field(None, min_length=3, max_length=50) # None = auto-generate
coins_amount: int = Field(..., ge=1, le=100000)
max_uses: int | None = Field(None, ge=1) # None = unlimited
valid_from: datetime | None = None
valid_until: datetime | None = None
class PromoCodeUpdate(BaseModel):
"""Schema for updating a promo code"""
is_active: bool | None = None
max_uses: int | None = None
valid_until: datetime | None = None
# === Response ===
class PromoCodeResponse(BaseModel):
"""Schema for promo code in responses"""
id: int
code: str
coins_amount: int
max_uses: int | None
uses_count: int
is_active: bool
valid_from: datetime | None
valid_until: datetime | None
created_at: datetime
created_by_nickname: str | None = None
class Config:
from_attributes = True
class PromoCodeRedemptionUser(BaseModel):
"""User info for redemption"""
id: int
nickname: str
class PromoCodeRedemptionResponse(BaseModel):
"""Schema for redemption record"""
id: int
user: PromoCodeRedemptionUser
coins_awarded: int
redeemed_at: datetime
class Config:
from_attributes = True
# === Redeem ===
class PromoCodeRedeemRequest(BaseModel):
"""Schema for redeeming a promo code"""
code: str = Field(..., min_length=1, max_length=50)
class PromoCodeRedeemResponse(BaseModel):
"""Schema for redeem response"""
success: bool
coins_awarded: int
new_balance: int
message: str

216
backend/app/schemas/shop.py Normal file
View File

@@ -0,0 +1,216 @@
"""
Pydantic schemas for Shop system
"""
from datetime import datetime
from pydantic import BaseModel, Field
from typing import Any
# === Shop Items ===
class ShopItemBase(BaseModel):
"""Base schema for shop items"""
item_type: str
code: str
name: str
description: str | None = None
price: int
rarity: str = "common"
asset_data: dict | None = None
class ShopItemCreate(ShopItemBase):
"""Schema for creating a shop item (admin)"""
is_active: bool = True
available_from: datetime | None = None
available_until: datetime | None = None
stock_limit: int | None = None
class ShopItemUpdate(BaseModel):
"""Schema for updating a shop item (admin)"""
name: str | None = None
description: str | None = None
price: int | None = Field(None, ge=1)
rarity: str | None = None
asset_data: dict | None = None
is_active: bool | None = None
available_from: datetime | None = None
available_until: datetime | None = None
stock_limit: int | None = None
class ShopItemResponse(ShopItemBase):
"""Schema for shop item response"""
id: int
is_active: bool
available_from: datetime | None
available_until: datetime | None
stock_limit: int | None
stock_remaining: int | None
created_at: datetime
is_available: bool # Computed property
is_owned: bool = False # Set by API based on user
is_equipped: bool = False # Set by API based on user
class Config:
from_attributes = True
# === Inventory ===
class InventoryItemResponse(BaseModel):
"""Schema for user inventory item"""
id: int
item: ShopItemResponse
quantity: int
equipped: bool
purchased_at: datetime
expires_at: datetime | None
class Config:
from_attributes = True
# === Purchases ===
class PurchaseRequest(BaseModel):
"""Schema for purchase request"""
item_id: int
quantity: int = Field(default=1, ge=1, le=10)
class PurchaseResponse(BaseModel):
"""Schema for purchase response"""
success: bool
item: ShopItemResponse
quantity: int
total_cost: int
new_balance: int
message: str
# === Consumables ===
class UseConsumableRequest(BaseModel):
"""Schema for using a consumable"""
item_code: str # 'skip', 'boost', 'wild_card', 'lucky_dice', 'copycat', 'undo'
marathon_id: int
assignment_id: int | None = None # Required for skip, wild_card, copycat
game_id: int | None = None # Required for wild_card
target_participant_id: int | None = None # Required for copycat
class UseConsumableResponse(BaseModel):
"""Schema for consumable use response"""
success: bool
item_code: str
remaining_quantity: int
effect_description: str
effect_data: dict | None = None
# === Equipment ===
class EquipItemRequest(BaseModel):
"""Schema for equipping an item"""
inventory_id: int
class EquipItemResponse(BaseModel):
"""Schema for equip response"""
success: bool
item_type: str
equipped_item: ShopItemResponse | None
message: str
# === Coins ===
class CoinTransactionResponse(BaseModel):
"""Schema for coin transaction"""
id: int
amount: int
transaction_type: str
description: str | None
reference_type: str | None
reference_id: int | None
created_at: datetime
class Config:
from_attributes = True
class CoinsBalanceResponse(BaseModel):
"""Schema for coins balance with recent transactions"""
balance: int
recent_transactions: list[CoinTransactionResponse]
class AdminCoinsRequest(BaseModel):
"""Schema for admin coin operations"""
amount: int = Field(..., ge=1)
reason: str = Field(..., min_length=1, max_length=500)
# === User Cosmetics ===
class UserCosmeticsResponse(BaseModel):
"""Schema for user's equipped cosmetics"""
frame: ShopItemResponse | None = None
title: ShopItemResponse | None = None
name_color: ShopItemResponse | None = None
background: ShopItemResponse | None = None
# === Certification ===
class CertificationRequestSchema(BaseModel):
"""Schema for requesting marathon certification"""
pass # No fields needed for now
class CertificationReviewRequest(BaseModel):
"""Schema for admin reviewing certification"""
approve: bool
rejection_reason: str | None = Field(None, max_length=1000)
class CertificationStatusResponse(BaseModel):
"""Schema for certification status"""
marathon_id: int
certification_status: str
is_certified: bool
certification_requested_at: datetime | None
certified_at: datetime | None
certified_by_nickname: str | None = None
rejection_reason: str | None = None
# === Consumables Status ===
class ConsumablesStatusResponse(BaseModel):
"""Schema for participant's consumables status in a marathon"""
skips_available: int # From inventory
skip_exiles_available: int = 0 # From inventory (skip with exile)
skips_used: int # In this marathon
skips_remaining: int | None # Based on marathon limit
boosts_available: int # From inventory
has_active_boost: bool # Currently activated (one-time for current assignment)
boost_multiplier: float | None # 1.5 if boost active
wild_cards_available: int # From inventory
lucky_dice_available: int # From inventory
has_lucky_dice: bool # Currently activated
lucky_dice_multiplier: float | None # Rolled multiplier if active
copycats_available: int # From inventory
undos_available: int # From inventory
can_undo: bool # Has drop data to undo
# === Admin Item Granting ===
class AdminGrantItemRequest(BaseModel):
"""Schema for admin granting item to user"""
item_id: int
quantity: int = Field(default=1, ge=1, le=100)
reason: str = Field(..., min_length=1, max_length=500)

View File

@@ -28,6 +28,19 @@ class UserUpdate(BaseModel):
nickname: str | None = Field(None, min_length=2, max_length=50) nickname: str | None = Field(None, min_length=2, max_length=50)
class ShopItemPublic(BaseModel):
"""Minimal shop item info for public display"""
id: int
code: str
name: str
item_type: str
rarity: str
asset_data: dict | None = None
class Config:
from_attributes = True
class UserPublic(UserBase): class UserPublic(UserBase):
"""Public user info visible to other users - minimal data""" """Public user info visible to other users - minimal data"""
id: int id: int
@@ -35,6 +48,11 @@ class UserPublic(UserBase):
role: str = "user" role: str = "user"
telegram_avatar_url: str | None = None # Only TG avatar is public telegram_avatar_url: str | None = None # Only TG avatar is public
created_at: datetime created_at: datetime
# Shop: equipped cosmetics (visible to others)
equipped_frame: ShopItemPublic | None = None
equipped_title: ShopItemPublic | None = None
equipped_name_color: ShopItemPublic | None = None
equipped_background: ShopItemPublic | None = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -51,6 +69,8 @@ class UserPrivate(UserPublic):
notify_events: bool = True notify_events: bool = True
notify_disputes: bool = True notify_disputes: bool = True
notify_moderation: bool = True notify_moderation: bool = True
# Shop: coins balance (only visible to self)
coins_balance: int = 0
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
@@ -82,8 +102,15 @@ class UserProfilePublic(BaseModel):
id: int id: int
nickname: str nickname: str
avatar_url: str | None = None avatar_url: str | None = None
telegram_avatar_url: str | None = None
role: str = "user"
created_at: datetime created_at: datetime
stats: UserStats stats: UserStats
# Equipped cosmetics
equipped_frame: ShopItemPublic | None = None
equipped_title: ShopItemPublic | None = None
equipped_name_color: ShopItemPublic | None = None
equipped_background: ShopItemPublic | None = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -0,0 +1,79 @@
from pydantic import BaseModel
from datetime import datetime
# === Token schemas ===
class WidgetTokenCreate(BaseModel):
"""Создание токена виджета"""
pass # Не требует параметров
class WidgetTokenResponse(BaseModel):
"""Ответ с токеном виджета"""
id: int
token: str
created_at: datetime
expires_at: datetime | None
is_active: bool
urls: dict[str, str] # Готовые URL для виджетов
class Config:
from_attributes = True
class WidgetTokenListItem(BaseModel):
"""Элемент списка токенов"""
id: int
token: str
created_at: datetime
is_active: bool
class Config:
from_attributes = True
# === Widget data schemas ===
class WidgetLeaderboardEntry(BaseModel):
"""Запись в лидерборде виджета"""
rank: int
nickname: str
avatar_url: str | None
total_points: int
current_streak: int
is_current_user: bool # Для подсветки
class WidgetLeaderboardResponse(BaseModel):
"""Ответ лидерборда для виджета"""
entries: list[WidgetLeaderboardEntry]
current_user_rank: int | None
total_participants: int
marathon_title: str
class WidgetCurrentResponse(BaseModel):
"""Текущее задание для виджета"""
has_assignment: bool
game_title: str | None = None
game_cover_url: str | None = None
assignment_type: str | None = None # "challenge" | "playthrough"
challenge_title: str | None = None
challenge_description: str | None = None
points: int | None = None
difficulty: str | None = None # easy, medium, hard
bonus_completed: int | None = None # Для прохождений
bonus_total: int | None = None
class WidgetProgressResponse(BaseModel):
"""Прогресс участника для виджета"""
nickname: str
avatar_url: str | None
rank: int
total_points: int
current_streak: int
completed_count: int
dropped_count: int
marathon_title: str

View File

@@ -0,0 +1,288 @@
"""
Coins Service - handles all coin-related operations
Coins are earned only in certified marathons and can be spent in the shop.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User, Participant, Marathon, CoinTransaction, CoinTransactionType
from app.models.challenge import Difficulty
class CoinsService:
"""Service for managing coin transactions and balances"""
# Coins awarded per challenge difficulty (only in certified marathons)
CHALLENGE_COINS = {
Difficulty.EASY.value: 10,
Difficulty.MEDIUM.value: 20,
Difficulty.HARD.value: 35,
}
# Coins for playthrough = points * this ratio
PLAYTHROUGH_COIN_RATIO = 0.10 # 10% of points
# Coins awarded for marathon placements
MARATHON_PLACE_COINS = {
1: 500, # 1st place
2: 250, # 2nd place
3: 150, # 3rd place
}
# Bonus coins for Common Enemy event winners
COMMON_ENEMY_BONUS_COINS = {
1: 15, # First to complete
2: 10, # Second
3: 5, # Third
}
async def award_challenge_coins(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
difficulty: str,
assignment_id: int,
) -> int:
"""
Award coins for completing a challenge.
Only awards coins if marathon is certified.
Returns: number of coins awarded (0 if marathon not certified)
"""
if not marathon.is_certified:
return 0
coins = self.CHALLENGE_COINS.get(difficulty, 0)
if coins <= 0:
return 0
# Create transaction
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.CHALLENGE_COMPLETE.value,
reference_type="assignment",
reference_id=assignment_id,
description=f"Challenge completion ({difficulty})",
)
db.add(transaction)
# Update balances
user.coins_balance += coins
participant.coins_earned += coins
return coins
async def award_playthrough_coins(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
points: int,
assignment_id: int,
) -> int:
"""
Award coins for completing a playthrough.
Coins = points * PLAYTHROUGH_COIN_RATIO
Returns: number of coins awarded (0 if marathon not certified)
"""
if not marathon.is_certified:
return 0
coins = int(points * self.PLAYTHROUGH_COIN_RATIO)
if coins <= 0:
return 0
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.PLAYTHROUGH_COMPLETE.value,
reference_type="assignment",
reference_id=assignment_id,
description=f"Playthrough completion ({points} points)",
)
db.add(transaction)
user.coins_balance += coins
participant.coins_earned += coins
return coins
async def award_marathon_place(
self,
db: AsyncSession,
user: User,
marathon: Marathon,
place: int,
) -> int:
"""
Award coins for placing in a marathon (1st, 2nd, 3rd).
Returns: number of coins awarded (0 if not top 3 or not certified)
"""
if not marathon.is_certified:
return 0
coins = self.MARATHON_PLACE_COINS.get(place, 0)
if coins <= 0:
return 0
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.MARATHON_PLACE.value,
reference_type="marathon",
reference_id=marathon.id,
description=f"Marathon #{place} place: {marathon.title}",
)
db.add(transaction)
user.coins_balance += coins
return coins
async def award_common_enemy_bonus(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
rank: int,
event_id: int,
) -> int:
"""
Award bonus coins for Common Enemy event completion.
Returns: number of bonus coins awarded
"""
if not marathon.is_certified:
return 0
coins = self.COMMON_ENEMY_BONUS_COINS.get(rank, 0)
if coins <= 0:
return 0
transaction = CoinTransaction(
user_id=user.id,
amount=coins,
transaction_type=CoinTransactionType.COMMON_ENEMY_BONUS.value,
reference_type="event",
reference_id=event_id,
description=f"Common Enemy #{rank} place",
)
db.add(transaction)
user.coins_balance += coins
participant.coins_earned += coins
return coins
async def spend_coins(
self,
db: AsyncSession,
user: User,
amount: int,
description: str,
reference_type: str | None = None,
reference_id: int | None = None,
) -> bool:
"""
Spend coins (for purchases).
Returns: True if successful, False if insufficient balance
"""
if user.coins_balance < amount:
return False
transaction = CoinTransaction(
user_id=user.id,
amount=-amount, # Negative for spending
transaction_type=CoinTransactionType.PURCHASE.value,
reference_type=reference_type,
reference_id=reference_id,
description=description,
)
db.add(transaction)
user.coins_balance -= amount
return True
async def refund_coins(
self,
db: AsyncSession,
user: User,
amount: int,
description: str,
reference_type: str | None = None,
reference_id: int | None = None,
) -> None:
"""Refund coins to user (for failed purchases, etc.)"""
transaction = CoinTransaction(
user_id=user.id,
amount=amount,
transaction_type=CoinTransactionType.REFUND.value,
reference_type=reference_type,
reference_id=reference_id,
description=description,
)
db.add(transaction)
user.coins_balance += amount
async def admin_grant_coins(
self,
db: AsyncSession,
user: User,
amount: int,
reason: str,
admin_id: int,
) -> None:
"""Admin grants coins to user"""
transaction = CoinTransaction(
user_id=user.id,
amount=amount,
transaction_type=CoinTransactionType.ADMIN_GRANT.value,
reference_type="admin",
reference_id=admin_id,
description=f"Admin grant: {reason}",
)
db.add(transaction)
user.coins_balance += amount
async def admin_deduct_coins(
self,
db: AsyncSession,
user: User,
amount: int,
reason: str,
admin_id: int,
) -> bool:
"""
Admin deducts coins from user.
Returns: True if successful, False if insufficient balance
"""
if user.coins_balance < amount:
return False
transaction = CoinTransaction(
user_id=user.id,
amount=-amount,
transaction_type=CoinTransactionType.ADMIN_DEDUCT.value,
reference_type="admin",
reference_id=admin_id,
description=f"Admin deduction: {reason}",
)
db.add(transaction)
user.coins_balance -= amount
return True
# Singleton instance
coins_service = CoinsService()

View File

@@ -0,0 +1,721 @@
"""
Consumables Service - handles consumable items usage
Consumables:
- skip: Skip current assignment without penalty
- skip_exile: Skip + permanently exile game from pool
- boost: x1.5 multiplier for current assignment
- wild_card: Choose a game, get random challenge from it
- lucky_dice: Random multiplier (0.5, 1.0, 1.5, 2.0, 2.5, 3.0)
- copycat: Copy another participant's assignment
- undo: Restore points and streak from last drop
"""
import random
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
User, Participant, Marathon, Assignment, AssignmentStatus,
ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge,
BonusAssignment, ExiledGame, GameType
)
class ConsumablesService:
"""Service for consumable items"""
# Boost settings
BOOST_MULTIPLIER = 1.5
# Lucky Dice multipliers (equal probability, starts from 1.5x)
LUCKY_DICE_MULTIPLIERS = [1.5, 2.0, 2.5, 3.0, 3.5, 4.0]
async def use_skip(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
) -> dict:
"""
Use a Skip to bypass current assignment without penalty.
- No streak loss
- No drop penalty
- Assignment marked as dropped but without negative effects
Returns: dict with result info
Raises:
HTTPException: If skips not allowed or limit reached
"""
# Check marathon settings
if not marathon.allow_skips:
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
if marathon.max_skips_per_participant is not None:
if participant.skips_used >= marathon.max_skips_per_participant:
raise HTTPException(
status_code=400,
detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)"
)
# Check assignment is active
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only skip active assignments")
# Consume skip from inventory
item = await self._consume_item(db, user, ConsumableType.SKIP.value)
# Mark assignment as dropped (but without penalty)
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Note: We do NOT increase drop_count or reset streak
# Track skip usage
participant.skips_used += 1
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "skip",
"skipped_without_penalty": True,
},
)
db.add(usage)
return {
"success": True,
"skipped": True,
"penalty": 0,
"streak_preserved": True,
}
async def use_skip_exile(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
) -> dict:
"""
Use Skip with Exile - skip assignment AND permanently exile game from pool.
- No streak loss
- No drop penalty
- Game is permanently excluded from participant's pool
Returns: dict with result info
Raises:
HTTPException: If skips not allowed or limit reached
"""
# Check marathon settings (same as regular skip)
if not marathon.allow_skips:
raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon")
if marathon.max_skips_per_participant is not None:
if participant.skips_used >= marathon.max_skips_per_participant:
raise HTTPException(
status_code=400,
detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)"
)
# Check assignment is active
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only skip active assignments")
# Get game_id (different for playthrough vs challenges)
if assignment.is_playthrough:
game_id = assignment.game_id
else:
# Load challenge to get game_id
result = await db.execute(
select(Challenge).where(Challenge.id == assignment.challenge_id)
)
challenge = result.scalar_one()
game_id = challenge.game_id
# Check if game is already exiled
existing = await db.execute(
select(ExiledGame).where(
ExiledGame.participant_id == participant.id,
ExiledGame.game_id == game_id,
ExiledGame.is_active == True,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Game is already exiled")
# Consume skip_exile from inventory
item = await self._consume_item(db, user, ConsumableType.SKIP_EXILE.value)
# Mark assignment as dropped (without penalty)
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Track skip usage
participant.skips_used += 1
# Add game to exiled list
exiled = ExiledGame(
participant_id=participant.id,
game_id=game_id,
assignment_id=assignment.id,
exiled_by="user",
)
db.add(exiled)
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "skip_exile",
"skipped_without_penalty": True,
"game_exiled": True,
"game_id": game_id,
},
)
db.add(usage)
return {
"success": True,
"skipped": True,
"exiled": True,
"game_id": game_id,
"penalty": 0,
"streak_preserved": True,
}
async def use_boost(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Activate a Boost - multiplies points for current assignment on complete.
- Points for completed challenge are multiplied by BOOST_MULTIPLIER
- One-time use (consumed on complete)
Returns: dict with result info
Raises:
HTTPException: If consumables not allowed or boost already active
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_active_boost:
raise HTTPException(status_code=400, detail="Boost is already activated")
# Consume boost from inventory
item = await self._consume_item(db, user, ConsumableType.BOOST.value)
# Activate boost (one-time use)
participant.has_active_boost = True
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "boost",
"multiplier": self.BOOST_MULTIPLIER,
"one_time": True,
},
)
db.add(usage)
return {
"success": True,
"boost_activated": True,
"multiplier": self.BOOST_MULTIPLIER,
}
async def use_wild_card(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
game_id: int,
) -> dict:
"""
Use Wild Card - choose a game and switch to it.
For challenges game type:
- New challenge is randomly selected from the chosen game
- Assignment becomes a regular challenge
For playthrough game type:
- Assignment becomes a playthrough of the chosen game
- Bonus assignments are created from game's challenges
Returns: dict with new assignment info
Raises:
HTTPException: If game not in marathon or no challenges available
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only use wild card on active assignments")
# Verify game is in this marathon and load challenges
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(
Game.id == game_id,
Game.marathon_id == marathon.id,
)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=400, detail="Game not found in this marathon")
# Store old assignment info for logging
old_game_id = assignment.game_id
old_challenge_id = assignment.challenge_id
old_is_playthrough = assignment.is_playthrough
# Consume wild card from inventory
item = await self._consume_item(db, user, ConsumableType.WILD_CARD.value)
# Delete existing bonus assignments if any
if assignment.bonus_assignments:
for ba in assignment.bonus_assignments:
await db.delete(ba)
new_challenge_id = None
new_challenge_title = None
if game.game_type == GameType.PLAYTHROUGH.value:
# Switch to playthrough mode
assignment.game_id = game_id
assignment.challenge_id = None
assignment.is_playthrough = True
# Create bonus assignments from game's challenges
for ch in game.challenges:
bonus = BonusAssignment(
main_assignment_id=assignment.id,
challenge_id=ch.id,
)
db.add(bonus)
else:
# Switch to challenge mode - get random challenge
if not game.challenges:
raise HTTPException(status_code=400, detail="No challenges available for this game")
new_challenge = random.choice(game.challenges)
new_challenge_id = new_challenge.id
new_challenge_title = new_challenge.title
assignment.game_id = game_id
assignment.challenge_id = new_challenge_id
assignment.is_playthrough = False
# Reset timestamps since it's a new assignment
assignment.started_at = datetime.utcnow()
assignment.deadline = None
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "wild_card",
"old_game_id": old_game_id,
"old_challenge_id": old_challenge_id,
"old_is_playthrough": old_is_playthrough,
"new_game_id": game_id,
"new_challenge_id": new_challenge_id,
"new_is_playthrough": game.game_type == GameType.PLAYTHROUGH.value,
},
)
db.add(usage)
return {
"success": True,
"game_id": game_id,
"game_name": game.title,
"game_type": game.game_type,
"is_playthrough": game.game_type == GameType.PLAYTHROUGH.value,
"challenge_id": new_challenge_id,
"challenge_title": new_challenge_title,
}
async def use_lucky_dice(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Use Lucky Dice - get a random multiplier for current assignment.
- Random multiplier from [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
- Applied on next complete (stacks with boost if both active)
- One-time use
Returns: dict with rolled multiplier
Raises:
HTTPException: If consumables not allowed or lucky dice already active
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_lucky_dice:
raise HTTPException(status_code=400, detail="Lucky Dice is already active")
# Consume lucky dice from inventory
item = await self._consume_item(db, user, ConsumableType.LUCKY_DICE.value)
# Roll the dice
multiplier = random.choice(self.LUCKY_DICE_MULTIPLIERS)
# Activate lucky dice
participant.has_lucky_dice = True
participant.lucky_dice_multiplier = multiplier
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "lucky_dice",
"multiplier": multiplier,
},
)
db.add(usage)
return {
"success": True,
"lucky_dice_activated": True,
"multiplier": multiplier,
}
async def use_copycat(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
target_participant_id: int,
) -> dict:
"""
Use Copycat - copy another participant's assignment.
- Current assignment is replaced with target's current/last assignment
- Can copy even if target already completed theirs
- Cannot copy your own assignment
Returns: dict with copied assignment info
Raises:
HTTPException: If target not found or no assignment to copy
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Can only use copycat on active assignments")
if target_participant_id == participant.id:
raise HTTPException(status_code=400, detail="Cannot copy your own assignment")
# Find target participant
result = await db.execute(
select(Participant)
.where(
Participant.id == target_participant_id,
Participant.marathon_id == marathon.id,
)
)
target_participant = result.scalar_one_or_none()
if not target_participant:
raise HTTPException(status_code=400, detail="Target participant not found")
# Get target's most recent assignment (active or completed)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge),
selectinload(Assignment.game).selectinload(Game.challenges),
)
.where(
Assignment.participant_id == target_participant_id,
Assignment.status.in_([
AssignmentStatus.ACTIVE.value,
AssignmentStatus.COMPLETED.value
])
)
.order_by(Assignment.started_at.desc())
.limit(1)
)
target_assignment = result.scalar_one_or_none()
if not target_assignment:
raise HTTPException(status_code=400, detail="Target has no assignment to copy")
# Consume copycat from inventory
item = await self._consume_item(db, user, ConsumableType.COPYCAT.value)
# Store old assignment info for logging
old_game_id = assignment.game_id
old_challenge_id = assignment.challenge_id
old_is_playthrough = assignment.is_playthrough
# Copy the assignment - handle both challenge and playthrough
assignment.game_id = target_assignment.game_id
assignment.challenge_id = target_assignment.challenge_id
assignment.is_playthrough = target_assignment.is_playthrough
# Reset timestamps
assignment.started_at = datetime.utcnow()
assignment.deadline = None
# If copying a playthrough, recreate bonus assignments
if target_assignment.is_playthrough:
# Delete existing bonus assignments
for ba in assignment.bonus_assignments:
await db.delete(ba)
# Create new bonus assignments from target game's challenges
if target_assignment.game and target_assignment.game.challenges:
for ch in target_assignment.game.challenges:
bonus = BonusAssignment(
main_assignment_id=assignment.id,
challenge_id=ch.id,
)
db.add(bonus)
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
assignment_id=assignment.id,
effect_data={
"type": "copycat",
"old_challenge_id": old_challenge_id,
"old_game_id": old_game_id,
"old_is_playthrough": old_is_playthrough,
"copied_from_participant_id": target_participant_id,
"new_challenge_id": target_assignment.challenge_id,
"new_game_id": target_assignment.game_id,
"new_is_playthrough": target_assignment.is_playthrough,
},
)
db.add(usage)
# Prepare response
if target_assignment.is_playthrough:
title = f"Прохождение: {target_assignment.game.title}" if target_assignment.game else "Прохождение"
else:
title = target_assignment.challenge.title if target_assignment.challenge else None
return {
"success": True,
"copied": True,
"game_id": target_assignment.game_id,
"challenge_id": target_assignment.challenge_id,
"is_playthrough": target_assignment.is_playthrough,
"challenge_title": title,
}
async def use_undo(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
) -> dict:
"""
Use Undo - restore points and streak from last drop.
- Only works if there was a drop in this marathon
- Can only undo once per drop
- Restores both points and streak
Returns: dict with restored values
Raises:
HTTPException: If no drop to undo
"""
if not marathon.allow_consumables:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if not participant.can_undo:
raise HTTPException(status_code=400, detail="No drop to undo")
if participant.last_drop_points is None or participant.last_drop_streak_before is None:
raise HTTPException(status_code=400, detail="No drop data to restore")
# Consume undo from inventory
item = await self._consume_item(db, user, ConsumableType.UNDO.value)
# Store values for logging
points_restored = participant.last_drop_points
streak_restored = participant.last_drop_streak_before
current_points = participant.total_points
current_streak = participant.current_streak
# Restore points and streak
participant.total_points += points_restored
participant.current_streak = streak_restored
participant.drop_count = max(0, participant.drop_count - 1)
# Clear undo data
participant.can_undo = False
participant.last_drop_points = None
participant.last_drop_streak_before = None
# Log usage
usage = ConsumableUsage(
user_id=user.id,
item_id=item.id,
marathon_id=marathon.id,
effect_data={
"type": "undo",
"points_restored": points_restored,
"streak_restored_to": streak_restored,
"points_before": current_points,
"streak_before": current_streak,
},
)
db.add(usage)
return {
"success": True,
"undone": True,
"points_restored": points_restored,
"streak_restored": streak_restored,
"new_total_points": participant.total_points,
"new_streak": participant.current_streak,
}
async def _consume_item(
self,
db: AsyncSession,
user: User,
item_code: str,
) -> ShopItem:
"""
Consume 1 unit of a consumable from user's inventory.
Returns: The consumed ShopItem
Raises:
HTTPException: If user doesn't have the item
"""
result = await db.execute(
select(UserInventory)
.options(selectinload(UserInventory.item))
.join(ShopItem)
.where(
UserInventory.user_id == user.id,
ShopItem.code == item_code,
UserInventory.quantity > 0,
)
)
inv_item = result.scalar_one_or_none()
if not inv_item:
raise HTTPException(
status_code=400,
detail=f"You don't have any {item_code} in your inventory"
)
# Decrease quantity
inv_item.quantity -= 1
return inv_item.item
async def get_consumable_count(
self,
db: AsyncSession,
user_id: int,
item_code: str,
) -> int:
"""Get how many of a consumable user has"""
result = await db.execute(
select(UserInventory.quantity)
.join(ShopItem)
.where(
UserInventory.user_id == user_id,
ShopItem.code == item_code,
)
)
quantity = result.scalar_one_or_none()
return quantity or 0
def consume_boost_on_complete(self, participant: Participant) -> float:
"""
Consume boost when completing assignment (called from wheel.py).
One-time use - boost is consumed after single complete.
Returns: Multiplier value (BOOST_MULTIPLIER if boost was active, 1.0 otherwise)
"""
if participant.has_active_boost:
participant.has_active_boost = False
return self.BOOST_MULTIPLIER
return 1.0
def consume_lucky_dice_on_complete(self, participant: Participant) -> float:
"""
Consume lucky dice when completing assignment (called from wheel.py).
One-time use - consumed after single complete.
Returns: Multiplier value (rolled multiplier if active, 1.0 otherwise)
"""
if participant.has_lucky_dice and participant.lucky_dice_multiplier is not None:
multiplier = participant.lucky_dice_multiplier
participant.has_lucky_dice = False
participant.lucky_dice_multiplier = None
return multiplier
return 1.0
def save_drop_for_undo(
self,
participant: Participant,
points_lost: int,
streak_before: int,
) -> None:
"""
Save drop data for potential undo (called from wheel.py before dropping).
"""
participant.last_drop_points = points_lost
participant.last_drop_streak_before = streak_before
participant.can_undo = True
# Singleton instance
consumables_service = ConsumablesService()

View File

@@ -124,12 +124,6 @@ points: easy=20-40, medium=45-75, hard=90-150
points = ch.get("points", 30) points = ch.get("points", 30)
if not isinstance(points, int) or points < 1: if not isinstance(points, int) or points < 1:
points = 30 points = 30
if difficulty == "easy":
points = max(20, min(40, points))
elif difficulty == "medium":
points = max(45, min(75, points))
elif difficulty == "hard":
points = max(90, min(150, points))
return ChallengeGenerated( return ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100], title=ch.get("title", "Unnamed Challenge")[:100],

View File

@@ -66,7 +66,7 @@ class PointsService:
def calculate_drop_penalty( def calculate_drop_penalty(
self, self,
consecutive_drops: int, consecutive_drops: int,
challenge_points: int, challenge_points: int | None,
event: Event | None = None event: Event | None = None
) -> int: ) -> int:
""" """
@@ -80,6 +80,10 @@ class PointsService:
Returns: Returns:
Penalty points to subtract Penalty points to subtract
""" """
# No penalty if no points defined
if challenge_points is None:
return 0
# Double risk event = free drops # Double risk event = free drops
if event and event.type == EventType.DOUBLE_RISK.value: if event and event.type == EventType.DOUBLE_RISK.value:
return 0 return 0

View File

@@ -0,0 +1,297 @@
"""
Shop Service - handles shop items, purchases, and inventory management
"""
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import User, ShopItem, UserInventory, ShopItemType
from app.services.coins import coins_service
class ShopService:
"""Service for shop operations"""
async def get_available_items(
self,
db: AsyncSession,
item_type: str | None = None,
include_unavailable: bool = False,
) -> list[ShopItem]:
"""
Get list of shop items.
Args:
item_type: Filter by item type (frame, title, etc.)
include_unavailable: Include inactive/out of stock items
"""
query = select(ShopItem)
if item_type:
query = query.where(ShopItem.item_type == item_type)
if not include_unavailable:
now = datetime.utcnow()
query = query.where(
ShopItem.is_active == True,
(ShopItem.available_from.is_(None)) | (ShopItem.available_from <= now),
(ShopItem.available_until.is_(None)) | (ShopItem.available_until >= now),
(ShopItem.stock_remaining.is_(None)) | (ShopItem.stock_remaining > 0),
)
query = query.order_by(ShopItem.price.asc())
result = await db.execute(query)
return list(result.scalars().all())
async def get_item_by_id(self, db: AsyncSession, item_id: int) -> ShopItem | None:
"""Get shop item by ID"""
result = await db.execute(select(ShopItem).where(ShopItem.id == item_id))
return result.scalar_one_or_none()
async def get_item_by_code(self, db: AsyncSession, code: str) -> ShopItem | None:
"""Get shop item by code"""
result = await db.execute(select(ShopItem).where(ShopItem.code == code))
return result.scalar_one_or_none()
async def purchase_item(
self,
db: AsyncSession,
user: User,
item_id: int,
quantity: int = 1,
) -> tuple[UserInventory, int]:
"""
Purchase an item from the shop.
Args:
user: The purchasing user
item_id: ID of item to purchase
quantity: Number to purchase (only for consumables)
Returns:
Tuple of (inventory item, total cost)
Raises:
HTTPException: If item not found, not available, or insufficient funds
"""
# Get item
item = await self.get_item_by_id(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
# Check availability
if not item.is_available:
raise HTTPException(status_code=400, detail="Item is not available")
# For non-consumables, quantity is always 1
if item.item_type != ShopItemType.CONSUMABLE.value:
quantity = 1
# Check if already owned
existing = await db.execute(
select(UserInventory).where(
UserInventory.user_id == user.id,
UserInventory.item_id == item.id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="You already own this item")
# Check stock
if item.stock_remaining is not None and item.stock_remaining < quantity:
raise HTTPException(status_code=400, detail="Not enough stock available")
# Calculate total cost
total_cost = item.price * quantity
# Check balance
if user.coins_balance < total_cost:
raise HTTPException(status_code=400, detail="Not enough coins")
# Deduct coins
success = await coins_service.spend_coins(
db, user, total_cost,
f"Purchase: {item.name} x{quantity}",
"shop_item", item.id,
)
if not success:
raise HTTPException(status_code=400, detail="Payment failed")
# Add to inventory
if item.item_type == ShopItemType.CONSUMABLE.value:
# For consumables, increase quantity if already exists
existing_result = await db.execute(
select(UserInventory).where(
UserInventory.user_id == user.id,
UserInventory.item_id == item.id,
)
)
inv_item = existing_result.scalar_one_or_none()
if inv_item:
inv_item.quantity += quantity
else:
inv_item = UserInventory(
user_id=user.id,
item_id=item.id,
quantity=quantity,
)
db.add(inv_item)
else:
# For cosmetics, create new inventory entry
inv_item = UserInventory(
user_id=user.id,
item_id=item.id,
quantity=1,
)
db.add(inv_item)
# Decrease stock if limited
if item.stock_remaining is not None:
item.stock_remaining -= quantity
await db.flush()
return inv_item, total_cost
async def get_user_inventory(
self,
db: AsyncSession,
user_id: int,
item_type: str | None = None,
) -> list[UserInventory]:
"""Get user's inventory"""
query = (
select(UserInventory)
.options(selectinload(UserInventory.item))
.where(UserInventory.user_id == user_id)
)
if item_type:
query = query.join(ShopItem).where(ShopItem.item_type == item_type)
# Exclude empty consumables
query = query.where(UserInventory.quantity > 0)
result = await db.execute(query)
return list(result.scalars().all())
async def get_inventory_item(
self,
db: AsyncSession,
user_id: int,
inventory_id: int,
) -> UserInventory | None:
"""Get specific inventory item"""
result = await db.execute(
select(UserInventory)
.options(selectinload(UserInventory.item))
.where(
UserInventory.id == inventory_id,
UserInventory.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def equip_item(
self,
db: AsyncSession,
user: User,
inventory_id: int,
) -> ShopItem:
"""
Equip a cosmetic item from inventory.
Returns: The equipped item
Raises:
HTTPException: If item not found or is a consumable
"""
# Get inventory item
inv_item = await self.get_inventory_item(db, user.id, inventory_id)
if not inv_item:
raise HTTPException(status_code=404, detail="Item not found in inventory")
item = inv_item.item
if item.item_type == ShopItemType.CONSUMABLE.value:
raise HTTPException(status_code=400, detail="Cannot equip consumables")
# Unequip current item of same type
await db.execute(
update(UserInventory)
.where(
UserInventory.user_id == user.id,
UserInventory.equipped == True,
UserInventory.item_id.in_(
select(ShopItem.id).where(ShopItem.item_type == item.item_type)
),
)
.values(equipped=False)
)
# Equip new item
inv_item.equipped = True
# Update user's equipped_*_id
if item.item_type == ShopItemType.FRAME.value:
user.equipped_frame_id = item.id
elif item.item_type == ShopItemType.TITLE.value:
user.equipped_title_id = item.id
elif item.item_type == ShopItemType.NAME_COLOR.value:
user.equipped_name_color_id = item.id
elif item.item_type == ShopItemType.BACKGROUND.value:
user.equipped_background_id = item.id
return item
async def unequip_item(
self,
db: AsyncSession,
user: User,
item_type: str,
) -> None:
"""Unequip item of specified type"""
# Unequip from inventory
await db.execute(
update(UserInventory)
.where(
UserInventory.user_id == user.id,
UserInventory.equipped == True,
UserInventory.item_id.in_(
select(ShopItem.id).where(ShopItem.item_type == item_type)
),
)
.values(equipped=False)
)
# Clear user's equipped_*_id
if item_type == ShopItemType.FRAME.value:
user.equipped_frame_id = None
elif item_type == ShopItemType.TITLE.value:
user.equipped_title_id = None
elif item_type == ShopItemType.NAME_COLOR.value:
user.equipped_name_color_id = None
elif item_type == ShopItemType.BACKGROUND.value:
user.equipped_background_id = None
async def check_user_owns_item(
self,
db: AsyncSession,
user_id: int,
item_id: int,
) -> bool:
"""Check if user owns an item"""
result = await db.execute(
select(UserInventory).where(
UserInventory.user_id == user_id,
UserInventory.item_id == item_id,
UserInventory.quantity > 0,
)
)
return result.scalar_one_or_none() is not None
# Singleton instance
shop_service = ShopService()

View File

@@ -608,6 +608,57 @@ class TelegramNotifier:
reply_markup=reply_markup reply_markup=reply_markup
) )
async def notify_assignment_skipped_by_moderator(
self,
db,
user,
marathon_title: str,
game_title: str,
exiled: bool,
reason: str | None,
moderator_nickname: str,
) -> bool:
"""Notify participant that their assignment was skipped by organizer"""
if not user.telegram_id or not user.notify_moderation:
return False
exile_text = "\n🚫 Игра исключена из вашего пула" if exiled else ""
reason_text = f"\n📝 Причина: {reason}" if reason else ""
message = (
f"⏭️ <b>Задание пропущено</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n"
f"Организатор: {moderator_nickname}"
f"{exile_text}"
f"{reason_text}\n\n"
f"Вы можете крутить колесо заново."
)
return await self.send_message(user.telegram_id, message)
async def notify_item_granted(
self,
user,
item_name: str,
quantity: int,
reason: str,
admin_nickname: str,
) -> bool:
"""Notify user that they received an item from admin"""
if not user.telegram_id:
return False
message = (
f"🎁 <b>Вы получили подарок!</b>\n\n"
f"Предмет: {item_name}\n"
f"Количество: {quantity}\n"
f"От: {admin_nickname}\n"
f"Причина: {reason}"
)
return await self.send_message(user.telegram_id, message)
# Global instance # Global instance
telegram_notifier = TelegramNotifier() telegram_notifier = TelegramNotifier()

32
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Dependencies
node_modules/
# Build output
dist/
release/
# Logs
*.log
npm-debug.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
.claude/
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
.env.*.local
# Electron
*.asar
# Lock files (optional - remove if you want to commit)
package-lock.json

6893
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

89
desktop/package.json Normal file
View File

@@ -0,0 +1,89 @@
{
"name": "game-marathon-tracker",
"version": "1.0.1",
"description": "Desktop app for tracking game time in Game Marathon",
"main": "dist/main/main/index.js",
"author": "Game Marathon",
"license": "MIT",
"scripts": {
"dev": "concurrently -k \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"",
"dev:main": "tsc -p tsconfig.main.json --watch",
"dev:renderer": "vite",
"dev:electron": "wait-on http://localhost:5173 && electron .",
"build": "npm run build:main && npm run build:renderer",
"build:main": "tsc -p tsconfig.main.json",
"build:renderer": "vite build && node -e \"require('fs').copyFileSync('src/renderer/splash.html', 'dist/renderer/splash.html'); require('fs').copyFileSync('src/renderer/logo.jpg', 'dist/renderer/logo.jpg')\"",
"start": "electron .",
"pack": "electron-builder --dir",
"dist": "npm run build && electron-builder --win"
},
"dependencies": {
"auto-launch": "^5.0.6",
"axios": "^1.6.7",
"clsx": "^2.1.0",
"electron-store": "^8.1.0",
"electron-updater": "^6.7.3",
"lucide-react": "^0.323.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"tailwind-merge": "^2.2.1",
"vdf-parser": "^1.0.3",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/auto-launch": "^5.0.5",
"@types/node": "^20.11.16",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"concurrently": "^8.2.2",
"electron": "^28.2.0",
"electron-builder": "^24.9.1",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.0",
"wait-on": "^7.2.0"
},
"build": {
"appId": "com.gamemarathon.tracker",
"productName": "Game Marathon Tracker",
"directories": {
"output": "release"
},
"files": [
"dist/**/*"
],
"extraResources": [
{
"from": "resources",
"to": "resources"
}
],
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64"]
}
],
"icon": "resources/icon.ico",
"signAndEditExecutable": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"runAfterFinish": false,
"artifactName": "Game-Marathon-Tracker-Setup-${version}.${ext}"
},
"publish": {
"provider": "github",
"owner": "Oronemu",
"repo": "marathon_tracker"
}
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,2 @@
# Resources placeholder
# Add icon.ico and tray-icon.png here

BIN
desktop/resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

BIN
desktop/resources/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,145 @@
import https from 'https'
import http from 'http'
import { URL } from 'url'
import type { StoreType } from './storeTypes'
interface ApiResponse<T = unknown> {
data: T
status: number
}
interface ApiError {
status: number
message: string
detail?: unknown
}
export class ApiClient {
private store: StoreType
constructor(store: StoreType) {
this.store = store
}
private getBaseUrl(): string {
return this.store.get('settings').apiUrl || 'https://marathon.animeenigma.ru/api/v1'
}
private getToken(): string | null {
return this.store.get('token')
}
async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
endpoint: string,
data?: unknown
): Promise<ApiResponse<T>> {
const baseUrl = this.getBaseUrl().replace(/\/$/, '') // Remove trailing slash
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
const fullUrl = `${baseUrl}${cleanEndpoint}`
const url = new URL(fullUrl)
const token = this.getToken()
const isHttps = url.protocol === 'https:'
const httpModule = isHttps ? https : http
const body = data ? JSON.stringify(data) : undefined
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname + url.search,
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...(body ? { 'Content-Length': Buffer.byteLength(body) } : {}),
},
}
console.log(`[ApiClient] ${method} ${url.href}`)
return new Promise((resolve, reject) => {
const req = httpModule.request(options, (res) => {
let responseData = ''
res.on('data', (chunk) => {
responseData += chunk
})
res.on('end', () => {
console.log(`[ApiClient] Response status: ${res.statusCode}`)
console.log(`[ApiClient] Response body: ${responseData.substring(0, 500)}`)
try {
const parsed = responseData ? JSON.parse(responseData) : {}
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve({
data: parsed as T,
status: res.statusCode,
})
} else {
const error: ApiError = {
status: res.statusCode || 500,
message: parsed.detail || 'Request failed',
detail: parsed.detail,
}
reject(error)
}
} catch (e) {
console.error('[ApiClient] Parse error:', e)
console.error('[ApiClient] Raw response:', responseData)
reject({
status: res.statusCode || 500,
message: 'Failed to parse response',
})
}
})
})
req.on('error', (e) => {
console.error('[ApiClient] Request error:', e)
reject({
status: 0,
message: e.message || 'Network error',
})
})
req.setTimeout(30000, () => {
console.error('[ApiClient] Request timeout')
req.destroy()
reject({
status: 0,
message: 'Request timeout',
})
})
if (body) {
req.write(body)
}
req.end()
})
}
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>('GET', endpoint)
}
async post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('POST', endpoint, data)
}
async put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('PUT', endpoint, data)
}
async patch<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>('PATCH', endpoint, data)
}
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>('DELETE', endpoint)
}
}

View File

@@ -0,0 +1,42 @@
import AutoLaunch from 'auto-launch'
import { app } from 'electron'
let autoLauncher: AutoLaunch | null = null
export async function setupAutoLaunch(enabled: boolean): Promise<void> {
if (!autoLauncher) {
autoLauncher = new AutoLaunch({
name: 'Game Marathon Tracker',
path: app.getPath('exe'),
})
}
try {
const isEnabled = await autoLauncher.isEnabled()
if (enabled && !isEnabled) {
await autoLauncher.enable()
console.log('Auto-launch enabled')
} else if (!enabled && isEnabled) {
await autoLauncher.disable()
console.log('Auto-launch disabled')
}
} catch (error) {
console.error('Failed to setup auto-launch:', error)
}
}
export async function isAutoLaunchEnabled(): Promise<boolean> {
if (!autoLauncher) {
autoLauncher = new AutoLaunch({
name: 'Game Marathon Tracker',
path: app.getPath('exe'),
})
}
try {
return await autoLauncher.isEnabled()
} catch {
return false
}
}

197
desktop/src/main/index.ts Normal file
View File

@@ -0,0 +1,197 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import * as path from 'path'
import Store from 'electron-store'
import { setupTray, destroyTray } from './tray'
import { setupAutoLaunch } from './autolaunch'
import { setupIpcHandlers } from './ipc'
import { ProcessTracker } from './tracking/processTracker'
import { createSplashWindow, setupAutoUpdater, setupUpdateIpcHandlers } from './updater'
import type { StoreType } from './storeTypes'
import './storeTypes' // Import for global type declarations
// Initialize electron store
const store = new Store({
defaults: {
settings: {
autoLaunch: false,
minimizeToTray: true,
trackingInterval: 5000,
apiUrl: 'https://marathon.animeenigma.ru/api/v1',
theme: 'dark',
},
token: null,
trackedGames: {},
trackingData: {},
},
}) as StoreType
let mainWindow: BrowserWindow | null = null
let processTracker: ProcessTracker | null = null
let isMonitoringEnabled = false
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
// Prevent multiple instances
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.exit(0)
}
// Someone tried to run a second instance, focus our window
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus()
}
})
function createWindow() {
// In dev: use project resources folder, in prod: use app resources
const iconPath = isDev
? path.join(__dirname, '../../../resources/icon.ico')
: path.join(process.resourcesPath, 'resources/icon.ico')
mainWindow = new BrowserWindow({
width: 450,
height: 750,
resizable: false,
frame: false,
titleBarStyle: 'hidden',
backgroundColor: '#0d0e14',
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
},
icon: iconPath,
})
// Load the app
if (isDev) {
mainWindow.loadURL('http://localhost:5173')
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
// In production: __dirname is dist/main/main/, so go up twice to dist/renderer/
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'))
}
// Handle close to tray
mainWindow.on('close', (event) => {
const settings = store.get('settings')
if (settings.minimizeToTray && !app.isQuitting) {
event.preventDefault()
mainWindow?.hide()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
// Setup tray icon
setupTray(mainWindow, store)
return mainWindow
}
app.whenReady().then(async () => {
// Setup IPC handlers
setupIpcHandlers(store, () => mainWindow)
setupUpdateIpcHandlers()
// Show splash screen and check for updates
createSplashWindow()
setupAutoUpdater(async () => {
// This runs after update check is complete (or skipped)
// Create the main window
createWindow()
// Setup auto-launch
const settings = store.get('settings')
await setupAutoLaunch(settings.autoLaunch)
// Initialize process tracker (but don't start automatically)
processTracker = new ProcessTracker(
store,
(stats) => {
mainWindow?.webContents.send('tracking-update', stats)
},
(event) => {
// Game started
mainWindow?.webContents.send('game-started', event.gameName, event.gameId)
},
(event) => {
// Game stopped
mainWindow?.webContents.send('game-stopped', event.gameName, event.duration || 0)
}
)
// Don't start automatically - user will start via button
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
// Don't quit on Windows if minimize to tray is enabled
const settings = store.get('settings')
if (!settings.minimizeToTray) {
app.quit()
}
}
})
app.on('before-quit', () => {
app.isQuitting = true
processTracker?.stop()
destroyTray()
})
// Handle IPC for window controls
ipcMain.on('minimize-to-tray', () => {
mainWindow?.hide()
})
ipcMain.on('close-window', () => {
// This triggers the 'close' event handler which checks minimizeToTray setting
mainWindow?.close()
})
ipcMain.on('quit-app', () => {
app.isQuitting = true
app.quit()
})
// Monitoring control
ipcMain.handle('start-monitoring', () => {
if (!isMonitoringEnabled && processTracker) {
processTracker.start()
isMonitoringEnabled = true
console.log('Monitoring started')
}
return isMonitoringEnabled
})
ipcMain.handle('stop-monitoring', () => {
if (isMonitoringEnabled && processTracker) {
processTracker.stop()
isMonitoringEnabled = false
console.log('Monitoring stopped')
}
return isMonitoringEnabled
})
ipcMain.handle('get-monitoring-status', () => {
return isMonitoringEnabled
})
// Export for use in other modules
export { store, mainWindow }

174
desktop/src/main/ipc.ts Normal file
View File

@@ -0,0 +1,174 @@
import { ipcMain, BrowserWindow } from 'electron'
import { setupAutoLaunch } from './autolaunch'
import { getRunningProcesses, getForegroundWindow } from './tracking/processTracker'
import { getSteamGames, getSteamPath } from './tracking/steamIntegration'
import { getTrackingStats, getTrackedGames, addTrackedGame, removeTrackedGame } from './tracking/timeStorage'
import { ApiClient } from './apiClient'
import type { TrackedGame, AppSettings, User, LoginResponse } from '../shared/types'
import type { StoreType } from './storeTypes'
export function setupIpcHandlers(
store: StoreType,
getMainWindow: () => BrowserWindow | null
) {
const apiClient = new ApiClient(store)
// Settings handlers
ipcMain.handle('get-settings', () => {
return store.get('settings')
})
ipcMain.handle('save-settings', async (_event, settings: Partial<AppSettings>) => {
const currentSettings = store.get('settings')
const newSettings = { ...currentSettings, ...settings }
store.set('settings', newSettings)
// Handle auto-launch setting change
if (settings.autoLaunch !== undefined) {
await setupAutoLaunch(settings.autoLaunch)
}
return newSettings
})
// Auth handlers
ipcMain.handle('get-token', () => {
return store.get('token')
})
ipcMain.handle('save-token', (_event, token: string) => {
store.set('token', token)
})
ipcMain.handle('clear-token', () => {
store.set('token', null)
})
// Process tracking handlers
ipcMain.handle('get-running-processes', async () => {
return await getRunningProcesses()
})
ipcMain.handle('get-foreground-window', async () => {
return await getForegroundWindow()
})
ipcMain.handle('get-tracking-stats', () => {
return getTrackingStats(store)
})
// Steam handlers
ipcMain.handle('get-steam-games', async () => {
return await getSteamGames()
})
ipcMain.handle('get-steam-path', () => {
return getSteamPath()
})
// Tracked games handlers
ipcMain.handle('get-tracked-games', () => {
return getTrackedGames(store)
})
ipcMain.handle('add-tracked-game', (_event, game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => {
return addTrackedGame(store, game)
})
ipcMain.handle('remove-tracked-game', (_event, gameId: string) => {
removeTrackedGame(store, gameId)
})
// API handlers - all requests go through main process (no CORS issues)
ipcMain.handle('api-login', async (_event, login: string, password: string) => {
console.log('[API] Login attempt for:', login)
try {
const response = await apiClient.post<LoginResponse>('/auth/login', { login, password })
console.log('[API] Login response:', response.status)
// Save token if login successful
if (response.data.access_token) {
store.set('token', response.data.access_token)
}
return { success: true, data: response.data }
} catch (error: unknown) {
console.error('[API] Login error:', error)
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Login failed',
status: err.status
}
}
})
ipcMain.handle('api-get-me', async () => {
try {
const response = await apiClient.get<User>('/auth/me')
return { success: true, data: response.data }
} catch (error: unknown) {
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Failed to get user',
status: err.status
}
}
})
ipcMain.handle('api-2fa-verify', async (_event, sessionId: number, code: string) => {
console.log('[API] 2FA verify attempt')
try {
const response = await apiClient.post<LoginResponse>(`/auth/2fa/verify?session_id=${sessionId}&code=${code}`)
console.log('[API] 2FA verify response:', response.status)
// Save token if verification successful
if (response.data.access_token) {
store.set('token', response.data.access_token)
}
return { success: true, data: response.data }
} catch (error: unknown) {
console.error('[API] 2FA verify error:', error)
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || '2FA verification failed',
status: err.status
}
}
})
ipcMain.handle('api-request', async (_event, method: string, endpoint: string, data?: unknown) => {
try {
let response
switch (method.toUpperCase()) {
case 'GET':
response = await apiClient.get(endpoint)
break
case 'POST':
response = await apiClient.post(endpoint, data)
break
case 'PUT':
response = await apiClient.put(endpoint, data)
break
case 'PATCH':
response = await apiClient.patch(endpoint, data)
break
case 'DELETE':
response = await apiClient.delete(endpoint)
break
default:
throw new Error(`Unknown method: ${method}`)
}
return { success: true, data: response.data }
} catch (error: unknown) {
const err = error as { status?: number; message?: string; detail?: unknown }
return {
success: false,
error: err.detail || err.message || 'Request failed',
status: err.status
}
}
})
}

View File

@@ -0,0 +1,28 @@
import Store from 'electron-store'
import type { AppSettings, TrackedGame } from '../shared/types'
export interface GameTrackingData {
totalTime: number
sessions: Array<{
startTime: number
endTime: number
duration: number
}>
lastPlayed: number
}
export type StoreType = Store<{
settings: AppSettings
token: string | null
trackedGames: Record<string, TrackedGame>
trackingData: Record<string, GameTrackingData>
}>
// Extend Electron App type
declare global {
namespace Electron {
interface App {
isQuitting?: boolean
}
}
}

View File

@@ -0,0 +1,284 @@
import { exec } from 'child_process'
import { promisify } from 'util'
import type { TrackedProcess, TrackingStats, TrackedGame } from '../../shared/types'
import type { StoreType } from '../storeTypes'
import { updateGameTime, getTrackedGames } from './timeStorage'
import { updateTrayMenu } from '../tray'
const execAsync = promisify(exec)
interface ProcessInfo {
ProcessName: string
MainWindowTitle: string
Id: number
}
export async function getRunningProcesses(): Promise<TrackedProcess[]> {
try {
const { stdout } = await execAsync(
'powershell -NoProfile -Command "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Process | Where-Object {$_.MainWindowTitle} | Select-Object ProcessName, MainWindowTitle, Id | ConvertTo-Json -Compress"',
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }
)
if (!stdout.trim()) {
return []
}
let processes: ProcessInfo[]
try {
const parsed = JSON.parse(stdout)
processes = Array.isArray(parsed) ? parsed : [parsed]
} catch {
return []
}
return processes.map((proc) => ({
id: proc.Id.toString(),
name: proc.ProcessName,
displayName: proc.MainWindowTitle || proc.ProcessName,
windowTitle: proc.MainWindowTitle,
isGame: isLikelyGame(proc.ProcessName, proc.MainWindowTitle),
}))
} catch (error) {
console.error('Failed to get running processes:', error)
return []
}
}
export async function getForegroundWindow(): Promise<string | null> {
try {
// Use base64 encoded script to avoid escaping issues
const script = `
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class FGWindow {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}
"@
\$hwnd = [FGWindow]::GetForegroundWindow()
\$processId = 0
[void][FGWindow]::GetWindowThreadProcessId(\$hwnd, [ref]\$processId)
\$proc = Get-Process -Id \$processId -ErrorAction SilentlyContinue
if (\$proc) { Write-Output \$proc.ProcessName }
`
// Encode script as base64
const base64Script = Buffer.from(script, 'utf16le').toString('base64')
const { stdout } = await execAsync(
`powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${base64Script}`,
{ encoding: 'utf8', timeout: 5000 }
)
const result = stdout.trim()
return result || null
} catch (error) {
console.error('[getForegroundWindow] Error:', error)
return null
}
}
function isLikelyGame(processName: string, windowTitle: string): boolean {
const gameIndicators = [
'game', 'steam', 'epic', 'uplay', 'origin', 'battle.net',
'unity', 'unreal', 'godot', 'ue4', 'ue5',
]
const lowerName = processName.toLowerCase()
const lowerTitle = (windowTitle || '').toLowerCase()
// Check for common game launchers/engines
for (const indicator of gameIndicators) {
if (lowerName.includes(indicator) || lowerTitle.includes(indicator)) {
return true
}
}
// Exclude common non-game processes
const nonGameProcesses = [
'explorer', 'chrome', 'firefox', 'edge', 'opera', 'brave',
'code', 'idea', 'webstorm', 'pycharm', 'rider',
'discord', 'slack', 'teams', 'zoom', 'telegram',
'spotify', 'vlc', 'foobar', 'winamp',
'notepad', 'word', 'excel', 'powerpoint', 'outlook',
'cmd', 'powershell', 'terminal', 'windowsterminal',
]
for (const nonGame of nonGameProcesses) {
if (lowerName.includes(nonGame)) {
return false
}
}
return true
}
interface GameEvent {
gameName: string
gameId: string
duration?: number
}
export class ProcessTracker {
private intervalId: NodeJS.Timeout | null = null
private currentGame: string | null = null
private currentGameName: string | null = null
private sessionStart: number | null = null
private store: StoreType
private onUpdate: (stats: TrackingStats) => void
private onGameStarted: (event: GameEvent) => void
private onGameStopped: (event: GameEvent) => void
constructor(
store: StoreType,
onUpdate: (stats: TrackingStats) => void,
onGameStarted?: (event: GameEvent) => void,
onGameStopped?: (event: GameEvent) => void
) {
this.store = store
this.onUpdate = onUpdate
this.onGameStarted = onGameStarted || (() => {})
this.onGameStopped = onGameStopped || (() => {})
}
start() {
const settings = this.store.get('settings')
const interval = settings.trackingInterval || 5000
this.intervalId = setInterval(() => this.tick(), interval)
console.log(`Process tracker started with ${interval}ms interval`)
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = null
}
// End current session if any
if (this.currentGame && this.sessionStart) {
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
}
this.currentGame = null
this.sessionStart = null
console.log('Process tracker stopped')
}
private async tick() {
const foregroundProcess = await getForegroundWindow()
const trackedGames = getTrackedGames(this.store)
// Debug logging - ALWAYS log
console.log('[Tracker] Foreground:', foregroundProcess || 'NULL', '| Tracked:', trackedGames.length, 'games:', trackedGames.map(g => g.executableName).join(', ') || 'none')
// Find if foreground process matches any tracked game
let matchedGame: TrackedGame | null = null
if (foregroundProcess) {
const lowerForeground = foregroundProcess.toLowerCase().replace('.exe', '')
for (const game of trackedGames) {
const lowerExe = game.executableName.toLowerCase().replace('.exe', '')
// More flexible matching
const matches = lowerForeground === lowerExe ||
lowerForeground.includes(lowerExe) ||
lowerExe.includes(lowerForeground)
if (matches) {
console.log('[Tracker] MATCH:', foregroundProcess, '===', game.executableName)
matchedGame = game
break
}
}
}
// Handle game state changes
if (matchedGame && matchedGame.id !== this.currentGame) {
// New game started
if (this.currentGame && this.sessionStart && this.currentGameName) {
// End previous session
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
// Emit game stopped event for previous game
this.onGameStopped({
gameName: this.currentGameName,
gameId: this.currentGame,
duration
})
}
this.currentGame = matchedGame.id
this.currentGameName = matchedGame.name
this.sessionStart = Date.now()
console.log(`Started tracking: ${matchedGame.name}`)
updateTrayMenu(null, true, matchedGame.name)
// Emit game started event
this.onGameStarted({
gameName: matchedGame.name,
gameId: matchedGame.id
})
} else if (!matchedGame && this.currentGame) {
// Game stopped
if (this.sessionStart) {
const duration = Date.now() - this.sessionStart
updateGameTime(this.store, this.currentGame, duration)
// Emit game stopped event
if (this.currentGameName) {
this.onGameStopped({
gameName: this.currentGameName,
gameId: this.currentGame,
duration
})
}
}
console.log(`Stopped tracking: ${this.currentGame}`)
this.currentGame = null
this.currentGameName = null
this.sessionStart = null
updateTrayMenu(null, false)
}
// Emit update
const stats = this.getStats()
this.onUpdate(stats)
}
private getStats(): TrackingStats {
const trackedGames = getTrackedGames(this.store)
const now = Date.now()
const todayStart = new Date().setHours(0, 0, 0, 0)
const weekStart = now - 7 * 24 * 60 * 60 * 1000
const monthStart = now - 30 * 24 * 60 * 60 * 1000
let totalTimeToday = 0
let totalTimeWeek = 0
let totalTimeMonth = 0
// Add current session time if active
if (this.currentGame && this.sessionStart) {
const currentSessionTime = now - this.sessionStart
totalTimeToday += currentSessionTime
totalTimeWeek += currentSessionTime
totalTimeMonth += currentSessionTime
}
// This is a simplified version - full implementation would track sessions with timestamps
for (const game of trackedGames) {
totalTimeMonth += game.totalTime
// For simplicity, assume all recorded time is from this week/today
// A full implementation would store session timestamps
}
return {
totalTimeToday,
totalTimeWeek,
totalTimeMonth,
sessions: [],
currentGame: this.currentGameName,
currentSessionDuration: this.currentGame && this.sessionStart ? now - this.sessionStart : 0,
}
}
}

View File

@@ -0,0 +1,215 @@
import * as fs from 'fs'
import * as path from 'path'
import type { SteamGame } from '../../shared/types'
// Common Steam installation paths on Windows
const STEAM_PATHS = [
'C:\\Program Files (x86)\\Steam',
'C:\\Program Files\\Steam',
'D:\\Steam',
'D:\\SteamLibrary',
'E:\\Steam',
'E:\\SteamLibrary',
]
let cachedSteamPath: string | null = null
export function getSteamPath(): string | null {
if (cachedSteamPath) {
return cachedSteamPath
}
// Try common paths
for (const steamPath of STEAM_PATHS) {
if (fs.existsSync(path.join(steamPath, 'steam.exe')) ||
fs.existsSync(path.join(steamPath, 'steamapps'))) {
cachedSteamPath = steamPath
return steamPath
}
}
// Try to find via registry (would require node-winreg or similar)
// For now, just check common paths
return null
}
export async function getSteamGames(): Promise<SteamGame[]> {
const steamPath = getSteamPath()
if (!steamPath) {
console.log('Steam not found')
return []
}
const games: SteamGame[] = []
const libraryPaths = await getLibraryPaths(steamPath)
for (const libraryPath of libraryPaths) {
const steamAppsPath = path.join(libraryPath, 'steamapps')
if (!fs.existsSync(steamAppsPath)) continue
try {
const files = fs.readdirSync(steamAppsPath)
const manifests = files.filter((f) => f.startsWith('appmanifest_') && f.endsWith('.acf'))
for (const manifest of manifests) {
const game = await parseAppManifest(path.join(steamAppsPath, manifest), libraryPath)
if (game) {
games.push(game)
}
}
} catch (error) {
console.error(`Error reading steam apps from ${steamAppsPath}:`, error)
}
}
return games.sort((a, b) => a.name.localeCompare(b.name))
}
async function getLibraryPaths(steamPath: string): Promise<string[]> {
const paths: string[] = [steamPath]
const libraryFoldersPath = path.join(steamPath, 'steamapps', 'libraryfolders.vdf')
if (!fs.existsSync(libraryFoldersPath)) {
return paths
}
try {
const content = fs.readFileSync(libraryFoldersPath, 'utf8')
const libraryPaths = parseLibraryFolders(content)
paths.push(...libraryPaths.filter((p) => !paths.includes(p)))
} catch (error) {
console.error('Error reading library folders:', error)
}
return paths
}
function parseLibraryFolders(content: string): string[] {
const paths: string[] = []
// Simple VDF parser for library folders
// Format: "path" "C:\\SteamLibrary"
const pathRegex = /"path"\s+"([^"]+)"/g
let match
while ((match = pathRegex.exec(content)) !== null) {
const libPath = match[1].replace(/\\\\/g, '\\')
if (fs.existsSync(libPath)) {
paths.push(libPath)
}
}
return paths
}
async function parseAppManifest(manifestPath: string, libraryPath: string): Promise<SteamGame | null> {
try {
const content = fs.readFileSync(manifestPath, 'utf8')
const appIdMatch = content.match(/"appid"\s+"(\d+)"/)
const nameMatch = content.match(/"name"\s+"([^"]+)"/)
const installDirMatch = content.match(/"installdir"\s+"([^"]+)"/)
if (!appIdMatch || !nameMatch || !installDirMatch) {
return null
}
const appId = appIdMatch[1]
const name = nameMatch[1]
const installDir = installDirMatch[1]
// Filter out tools, servers, etc.
const skipTypes = ['Tool', 'Config', 'DLC', 'Music', 'Video']
const typeMatch = content.match(/"type"\s+"([^"]+)"/)
if (typeMatch && skipTypes.includes(typeMatch[1])) {
return null
}
const fullInstallPath = path.join(libraryPath, 'steamapps', 'common', installDir)
let executable: string | undefined
// Try to find main executable
if (fs.existsSync(fullInstallPath)) {
executable = findMainExecutable(fullInstallPath, name)
}
return {
appId,
name,
installDir: fullInstallPath,
executable,
iconPath: getGameIconPath(steamPath, appId),
}
} catch (error) {
console.error(`Error parsing manifest ${manifestPath}:`, error)
return null
}
}
function findMainExecutable(installPath: string, gameName: string): string | undefined {
try {
const files = fs.readdirSync(installPath)
const exeFiles = files.filter((f) => f.endsWith('.exe'))
if (exeFiles.length === 0) {
// Check subdirectories (one level deep)
for (const dir of files) {
const subPath = path.join(installPath, dir)
if (fs.statSync(subPath).isDirectory()) {
const subFiles = fs.readdirSync(subPath)
const subExe = subFiles.filter((f) => f.endsWith('.exe'))
exeFiles.push(...subExe.map((f) => path.join(dir, f)))
}
}
}
if (exeFiles.length === 0) return undefined
// Try to find exe that matches game name
const lowerName = gameName.toLowerCase().replace(/[^a-z0-9]/g, '')
for (const exe of exeFiles) {
const lowerExe = exe.toLowerCase().replace(/[^a-z0-9]/g, '')
if (lowerExe.includes(lowerName) || lowerName.includes(lowerExe.replace('.exe', ''))) {
return exe
}
}
// Filter out common non-game executables
const skipExes = [
'unins', 'setup', 'install', 'config', 'crash', 'report',
'launcher', 'updater', 'redistributable', 'vcredist', 'directx',
'dxsetup', 'ue4prereqsetup', 'dotnet',
]
const gameExes = exeFiles.filter((exe) => {
const lower = exe.toLowerCase()
return !skipExes.some((skip) => lower.includes(skip))
})
return gameExes[0] || exeFiles[0]
} catch {
return undefined
}
}
function getGameIconPath(steamPath: string | null, appId: string): string | undefined {
if (!steamPath) return undefined
// Steam stores icons in appcache/librarycache
const iconPath = path.join(steamPath, 'appcache', 'librarycache', `${appId}_icon.jpg`)
if (fs.existsSync(iconPath)) {
return iconPath
}
// Try header image
const headerPath = path.join(steamPath, 'appcache', 'librarycache', `${appId}_header.jpg`)
if (fs.existsSync(headerPath)) {
return headerPath
}
return undefined
}
// Re-export for use
const steamPath = getSteamPath()

View File

@@ -0,0 +1,155 @@
import type { TrackedGame, TrackingStats, GameSession } from '../../shared/types'
import type { StoreType, GameTrackingData } from '../storeTypes'
export type { GameTrackingData }
export function getTrackedGames(store: StoreType): TrackedGame[] {
const trackedGames = store.get('trackedGames') || {}
return Object.values(trackedGames)
}
export function addTrackedGame(
store: StoreType,
game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>
): TrackedGame {
const trackedGames = store.get('trackedGames') || {}
const newGame: TrackedGame = {
...game,
totalTime: 0,
lastPlayed: undefined,
}
trackedGames[game.id] = newGame
store.set('trackedGames', trackedGames)
// Initialize tracking data
const trackingData = store.get('trackingData') || {}
trackingData[game.id] = {
totalTime: 0,
sessions: [],
lastPlayed: 0,
}
store.set('trackingData', trackingData)
return newGame
}
export function removeTrackedGame(store: StoreType, gameId: string): void {
const trackedGames = store.get('trackedGames') || {}
delete trackedGames[gameId]
store.set('trackedGames', trackedGames)
const trackingData = store.get('trackingData') || {}
delete trackingData[gameId]
store.set('trackingData', trackingData)
}
export function updateGameTime(store: StoreType, gameId: string, duration: number): void {
// Update tracked games
const trackedGames = store.get('trackedGames') || {}
if (trackedGames[gameId]) {
trackedGames[gameId].totalTime += duration
trackedGames[gameId].lastPlayed = Date.now()
store.set('trackedGames', trackedGames)
}
// Update tracking data with session
const trackingData = store.get('trackingData') || {}
if (!trackingData[gameId]) {
trackingData[gameId] = {
totalTime: 0,
sessions: [],
lastPlayed: 0,
}
}
const now = Date.now()
trackingData[gameId].totalTime += duration
trackingData[gameId].lastPlayed = now
trackingData[gameId].sessions.push({
startTime: now - duration,
endTime: now,
duration,
})
// Keep only last 100 sessions to prevent data bloat
if (trackingData[gameId].sessions.length > 100) {
trackingData[gameId].sessions = trackingData[gameId].sessions.slice(-100)
}
store.set('trackingData', trackingData)
}
export function getTrackingStats(store: StoreType): TrackingStats {
const trackingData = store.get('trackingData') || {}
const now = Date.now()
const todayStart = new Date().setHours(0, 0, 0, 0)
const weekStart = now - 7 * 24 * 60 * 60 * 1000
const monthStart = now - 30 * 24 * 60 * 60 * 1000
let totalTimeToday = 0
let totalTimeWeek = 0
let totalTimeMonth = 0
const recentSessions: GameSession[] = []
for (const [gameId, data] of Object.entries(trackingData)) {
for (const session of data.sessions) {
if (session.endTime >= monthStart) {
totalTimeMonth += session.duration
if (session.endTime >= weekStart) {
totalTimeWeek += session.duration
}
if (session.endTime >= todayStart) {
totalTimeToday += session.duration
}
}
}
// Get last session for each game
if (data.sessions.length > 0) {
const lastSession = data.sessions[data.sessions.length - 1]
const trackedGames = store.get('trackedGames') || {}
const game = trackedGames[gameId]
if (game && lastSession.endTime >= weekStart) {
recentSessions.push({
gameId,
gameName: game.name,
startTime: lastSession.startTime,
endTime: lastSession.endTime,
duration: lastSession.duration,
isActive: false,
})
}
}
}
// Sort by most recent
recentSessions.sort((a, b) => (b.endTime || 0) - (a.endTime || 0))
return {
totalTimeToday,
totalTimeWeek,
totalTimeMonth,
sessions: recentSessions.slice(0, 10),
}
}
export function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
return `${hours}ч ${remainingMinutes}м`
} else if (minutes > 0) {
return `${minutes}м`
} else {
return `${seconds}с`
}
}

115
desktop/src/main/tray.ts Normal file
View File

@@ -0,0 +1,115 @@
import { Tray, Menu, nativeImage, BrowserWindow, app, NativeImage } from 'electron'
import * as path from 'path'
import type { StoreType } from './storeTypes'
let tray: Tray | null = null
export function setupTray(
mainWindow: BrowserWindow | null,
store: StoreType
) {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
// In dev: use project resources folder, in prod: use app resources
const iconPath = isDev
? path.join(__dirname, '../../../resources/icon.ico')
: path.join(process.resourcesPath, 'resources/icon.ico')
// Create tray icon
let trayIcon: NativeImage
try {
trayIcon = nativeImage.createFromPath(iconPath)
if (trayIcon.isEmpty()) {
trayIcon = nativeImage.createEmpty()
}
} catch {
trayIcon = nativeImage.createEmpty()
}
// Resize for tray (16x16 on Windows)
if (!trayIcon.isEmpty()) {
trayIcon = trayIcon.resize({ width: 16, height: 16 })
}
tray = new Tray(trayIcon)
tray.setToolTip('Game Marathon Tracker')
const contextMenu = Menu.buildFromTemplate([
{
label: 'Открыть',
click: () => {
mainWindow?.show()
mainWindow?.focus()
},
},
{ type: 'separator' },
{
label: 'Статус: Отслеживание',
enabled: false,
},
{ type: 'separator' },
{
label: 'Выход',
click: () => {
app.isQuitting = true
app.quit()
},
},
])
tray.setContextMenu(contextMenu)
// Double-click to show window
tray.on('double-click', () => {
mainWindow?.show()
mainWindow?.focus()
})
return tray
}
export function updateTrayMenu(
mainWindow: BrowserWindow | null,
isTracking: boolean,
currentGame?: string
) {
if (!tray) return
const statusLabel = isTracking
? `Отслеживание: ${currentGame || 'Активно'}`
: 'Отслеживание: Неактивно'
const contextMenu = Menu.buildFromTemplate([
{
label: 'Открыть',
click: () => {
mainWindow?.show()
mainWindow?.focus()
},
},
{ type: 'separator' },
{
label: statusLabel,
enabled: false,
},
{ type: 'separator' },
{
label: 'Выход',
click: () => {
app.isQuitting = true
app.quit()
},
},
])
tray.setContextMenu(contextMenu)
}
export function destroyTray() {
if (tray) {
tray.destroy()
tray = null
}
}
export { tray }

184
desktop/src/main/updater.ts Normal file
View File

@@ -0,0 +1,184 @@
import { autoUpdater } from 'electron-updater'
import { BrowserWindow, ipcMain, app } from 'electron'
import * as path from 'path'
let splashWindow: BrowserWindow | null = null
export function createSplashWindow(): BrowserWindow {
splashWindow = new BrowserWindow({
width: 350,
height: 250,
frame: false,
transparent: false,
resizable: false,
center: true,
backgroundColor: '#0d0e14',
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
})
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
if (isDev) {
// In dev mode: __dirname is dist/main/main/, need to go up 3 levels to project root
splashWindow.loadFile(path.join(__dirname, '../../../src/renderer/splash.html'))
} else {
// In production: __dirname is dist/main/main/, so go up twice to dist/renderer/
splashWindow.loadFile(path.join(__dirname, '../../renderer/splash.html'))
}
return splashWindow
}
export function closeSplashWindow() {
if (splashWindow) {
splashWindow.close()
splashWindow = null
}
}
function sendStatusToSplash(status: string) {
if (splashWindow) {
splashWindow.webContents.send('update-status', status)
}
}
function sendProgressToSplash(percent: number) {
if (splashWindow) {
splashWindow.webContents.send('update-progress', percent)
}
}
export function setupAutoUpdater(onComplete: () => void) {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
let hasCompleted = false
const safeComplete = () => {
if (hasCompleted) return
hasCompleted = true
closeSplashWindow()
onComplete()
}
// In development, skip update check
if (isDev) {
console.log('[Updater] Skipping update check in development mode')
sendStatusToSplash('Режим разработки')
setTimeout(safeComplete, 1500)
return
}
// Configure auto-updater
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
// Check for updates (use 'once' to prevent handlers from triggering on manual update checks)
autoUpdater.once('checking-for-update', () => {
console.log('[Updater] Checking for updates...')
sendStatusToSplash('Проверка обновлений...')
})
autoUpdater.once('update-available', (info) => {
console.log('[Updater] Update available:', info.version)
sendStatusToSplash(`Найдено обновление v${info.version}`)
})
autoUpdater.once('update-not-available', () => {
console.log('[Updater] No updates available')
sendStatusToSplash('Актуальная версия')
setTimeout(safeComplete, 1000)
})
autoUpdater.on('download-progress', (progress) => {
const percent = Math.round(progress.percent)
console.log(`[Updater] Download progress: ${percent}%`)
sendStatusToSplash(`Загрузка обновления... ${percent}%`)
sendProgressToSplash(percent)
})
autoUpdater.once('update-downloaded', (info) => {
console.log('[Updater] Update downloaded:', info.version)
sendStatusToSplash('Установка обновления...')
// Install and restart
setTimeout(() => {
autoUpdater.quitAndInstall(false, true)
}, 1500)
})
autoUpdater.once('error', (error) => {
console.error('[Updater] Error:', error.message)
console.error('[Updater] Error stack:', error.stack)
sendStatusToSplash('Запуск...')
setTimeout(safeComplete, 1500)
})
// Start checking
autoUpdater.checkForUpdates().catch((error) => {
console.error('[Updater] Failed to check for updates:', error)
sendStatusToSplash('Запуск...')
setTimeout(safeComplete, 1500)
})
}
// Manual check for updates (from settings)
export function checkForUpdatesManual(): Promise<{ available: boolean; version?: string; error?: string }> {
return new Promise((resolve) => {
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
if (isDev) {
resolve({ available: false, error: 'В режиме разработки обновления недоступны' })
return
}
const onUpdateAvailable = (info: { version: string }) => {
cleanup()
resolve({ available: true, version: info.version })
}
const onUpdateNotAvailable = () => {
cleanup()
resolve({ available: false })
}
const onError = (error: Error) => {
cleanup()
resolve({ available: false, error: error.message })
}
const cleanup = () => {
autoUpdater.off('update-available', onUpdateAvailable)
autoUpdater.off('update-not-available', onUpdateNotAvailable)
autoUpdater.off('error', onError)
}
autoUpdater.on('update-available', onUpdateAvailable)
autoUpdater.on('update-not-available', onUpdateNotAvailable)
autoUpdater.on('error', onError)
autoUpdater.checkForUpdates().catch((error) => {
cleanup()
resolve({ available: false, error: error.message })
})
})
}
// Setup IPC handlers for updates
export function setupUpdateIpcHandlers() {
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
ipcMain.handle('check-for-updates', async () => {
return await checkForUpdatesManual()
})
ipcMain.handle('download-update', () => {
autoUpdater.downloadUpdate()
})
ipcMain.handle('install-update', () => {
autoUpdater.quitAndInstall(false, true)
})
}

View File

@@ -0,0 +1,97 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { AppSettings, TrackedProcess, SteamGame, TrackedGame, TrackingStats, User, LoginResponse } from '../shared/types'
interface ApiResult<T> {
success: boolean
data?: T
error?: string
status?: number
}
// Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object
const electronAPI = {
// Settings
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('get-settings'),
saveSettings: (settings: Partial<AppSettings>): Promise<void> =>
ipcRenderer.invoke('save-settings', settings),
// Auth (local storage)
getToken: (): Promise<string | null> => ipcRenderer.invoke('get-token'),
saveToken: (token: string): Promise<void> => ipcRenderer.invoke('save-token', token),
clearToken: (): Promise<void> => ipcRenderer.invoke('clear-token'),
// API calls (through main process - no CORS)
apiLogin: (login: string, password: string): Promise<ApiResult<LoginResponse>> =>
ipcRenderer.invoke('api-login', login, password),
api2faVerify: (sessionId: number, code: string): Promise<ApiResult<LoginResponse>> =>
ipcRenderer.invoke('api-2fa-verify', sessionId, code),
apiGetMe: (): Promise<ApiResult<User>> =>
ipcRenderer.invoke('api-get-me'),
apiRequest: <T>(method: string, endpoint: string, data?: unknown): Promise<ApiResult<T>> =>
ipcRenderer.invoke('api-request', method, endpoint, data),
// Process tracking
getRunningProcesses: (): Promise<TrackedProcess[]> =>
ipcRenderer.invoke('get-running-processes'),
getForegroundWindow: (): Promise<string | null> =>
ipcRenderer.invoke('get-foreground-window'),
getTrackingStats: (): Promise<TrackingStats> =>
ipcRenderer.invoke('get-tracking-stats'),
// Steam
getSteamGames: (): Promise<SteamGame[]> => ipcRenderer.invoke('get-steam-games'),
getSteamPath: (): Promise<string | null> => ipcRenderer.invoke('get-steam-path'),
// Tracked games
getTrackedGames: (): Promise<TrackedGame[]> => ipcRenderer.invoke('get-tracked-games'),
addTrackedGame: (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>): Promise<TrackedGame> =>
ipcRenderer.invoke('add-tracked-game', game),
removeTrackedGame: (gameId: string): Promise<void> =>
ipcRenderer.invoke('remove-tracked-game', gameId),
// Window controls
minimizeToTray: (): void => ipcRenderer.send('minimize-to-tray'),
closeWindow: (): void => ipcRenderer.send('close-window'),
quitApp: (): void => ipcRenderer.send('quit-app'),
// Monitoring control
startMonitoring: (): Promise<boolean> => ipcRenderer.invoke('start-monitoring'),
stopMonitoring: (): Promise<boolean> => ipcRenderer.invoke('stop-monitoring'),
getMonitoringStatus: (): Promise<boolean> => ipcRenderer.invoke('get-monitoring-status'),
// Updates
getAppVersion: (): Promise<string> => ipcRenderer.invoke('get-app-version'),
checkForUpdates: (): Promise<{ available: boolean; version?: string; error?: string }> =>
ipcRenderer.invoke('check-for-updates'),
installUpdate: (): Promise<void> => ipcRenderer.invoke('install-update'),
// Events
onTrackingUpdate: (callback: (stats: TrackingStats) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, stats: TrackingStats) => callback(stats)
ipcRenderer.on('tracking-update', subscription)
return () => ipcRenderer.removeListener('tracking-update', subscription)
},
onGameStarted: (callback: (gameName: string, gameId: string) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, gameName: string, gameId: string) => callback(gameName, gameId)
ipcRenderer.on('game-started', subscription)
return () => ipcRenderer.removeListener('game-started', subscription)
},
onGameStopped: (callback: (gameName: string, duration: number) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, gameName: string, duration: number) =>
callback(gameName, duration)
ipcRenderer.on('game-stopped', subscription)
return () => ipcRenderer.removeListener('game-stopped', subscription)
},
}
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
// Type declaration for renderer process
declare global {
interface Window {
electronAPI: typeof electronAPI
}
}

View File

@@ -0,0 +1,65 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useEffect } from 'react'
import { useAuthStore } from './store/auth'
import { Layout } from './components/Layout'
import { LoginPage } from './pages/LoginPage'
import { DashboardPage } from './pages/DashboardPage'
import { SettingsPage } from './pages/SettingsPage'
import { GamesPage } from './pages/GamesPage'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuthStore()
if (isLoading) {
return (
<div className="min-h-screen bg-dark-900 flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-neon-500 border-t-transparent rounded-full" />
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <Layout>{children}</Layout>
}
export default function App() {
const { syncUser } = useAuthStore()
useEffect(() => {
syncUser()
}, [syncUser])
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/games"
element={
<ProtectedRoute>
<GamesPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,70 @@
import { ReactNode } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Gamepad2, Settings, LayoutDashboard, X, Minus } from 'lucide-react'
import { clsx } from 'clsx'
interface LayoutProps {
children: ReactNode
}
export function Layout({ children }: LayoutProps) {
const location = useLocation()
const navItems = [
{ path: '/', icon: LayoutDashboard, label: 'Главная' },
{ path: '/games', icon: Gamepad2, label: 'Игры' },
{ path: '/settings', icon: Settings, label: 'Настройки' },
]
return (
<div className="h-screen bg-dark-900 flex flex-col overflow-hidden">
{/* Custom title bar */}
<div className="titlebar h-8 bg-dark-950 flex items-center justify-between px-2 border-b border-dark-700">
<div className="flex items-center gap-2">
<Gamepad2 className="w-4 h-4 text-neon-500" />
<span className="text-xs font-medium text-gray-400">Game Marathon Tracker</span>
</div>
<div className="flex items-center">
<button
onClick={() => window.electronAPI.minimizeToTray()}
className="w-8 h-8 flex items-center justify-center hover:bg-dark-700 transition-colors"
>
<Minus className="w-4 h-4 text-gray-400" />
</button>
<button
onClick={() => window.electronAPI.closeWindow()}
className="w-8 h-8 flex items-center justify-center hover:bg-red-600 transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
</div>
{/* Main content */}
<div className="flex-1 overflow-auto p-4">{children}</div>
{/* Bottom navigation */}
<nav className="bg-dark-800 border-t border-dark-700 px-2 py-2">
<div className="flex justify-around">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
clsx(
'flex flex-col items-center gap-1 px-4 py-2 rounded-lg transition-all',
isActive
? 'text-neon-500 bg-neon-500/10'
: 'text-gray-400 hover:text-gray-300 hover:bg-dark-700'
)
}
>
<item.icon className="w-5 h-5" />
<span className="text-xs">{item.label}</span>
</NavLink>
))}
</div>
</nav>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { type ReactNode, type HTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
variant?: 'default' | 'dark' | 'neon'
hover?: boolean
glow?: boolean
className?: string
}
export function GlassCard({
children,
variant = 'default',
hover = false,
glow = false,
className,
...props
}: GlassCardProps) {
const variantClasses = {
default: 'glass',
dark: 'glass-dark',
neon: 'glass-neon',
}
return (
<div
className={clsx(
'rounded-xl p-4',
variantClasses[variant],
hover && 'card-hover cursor-pointer',
glow && 'neon-glow-pulse',
className
)}
{...props}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
icon?: React.ReactNode
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, icon, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-gray-300 mb-1.5">
{label}
</label>
)}
<div className="relative">
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{icon}
</div>
)}
<input
ref={ref}
className={clsx(
'w-full bg-dark-800 border border-dark-600 rounded-lg px-4 py-2.5',
'text-white placeholder-gray-500',
'transition-all duration-200',
'focus:border-neon-500/50 focus:ring-2 focus:ring-neon-500/10',
'disabled:opacity-50 disabled:cursor-not-allowed',
icon && 'pl-10',
error && 'border-red-500 focus:border-red-500 focus:ring-red-500/10',
className
)}
{...props}
/>
</div>
{error && (
<p className="mt-1.5 text-sm text-red-400">{error}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'

View File

@@ -0,0 +1,118 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
import { clsx } from 'clsx'
import { Loader2 } from 'lucide-react'
interface NeonButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
color?: 'neon' | 'purple' | 'pink'
isLoading?: boolean
icon?: ReactNode
iconPosition?: 'left' | 'right'
glow?: boolean
}
export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
color = 'neon',
isLoading,
icon,
iconPosition = 'left',
glow = true,
children,
disabled,
...props
},
ref
) => {
const colorMap = {
neon: {
primary: 'bg-neon-500 hover:bg-neon-400 text-dark-900',
secondary: 'bg-dark-600 hover:bg-dark-500 text-neon-400 border border-neon-500/30',
outline: 'bg-transparent border-2 border-neon-500 text-neon-500 hover:bg-neon-500 hover:text-dark-900',
ghost: 'bg-transparent hover:bg-neon-500/10 text-neon-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(34, 211, 238, 0.4)',
glowHover: '0 0 18px rgba(34, 211, 238, 0.55)',
},
purple: {
primary: 'bg-accent-500 hover:bg-accent-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-accent-400 border border-accent-500/30',
outline: 'bg-transparent border-2 border-accent-500 text-accent-500 hover:bg-accent-500 hover:text-white',
ghost: 'bg-transparent hover:bg-accent-500/10 text-accent-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(139, 92, 246, 0.4)',
glowHover: '0 0 18px rgba(139, 92, 246, 0.55)',
},
pink: {
primary: 'bg-pink-500 hover:bg-pink-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-pink-400 border border-pink-500/30',
outline: 'bg-transparent border-2 border-pink-500 text-pink-500 hover:bg-pink-500 hover:text-white',
ghost: 'bg-transparent hover:bg-pink-500/10 text-pink-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(244, 114, 182, 0.4)',
glowHover: '0 0 18px rgba(244, 114, 182, 0.55)',
},
}
const iconSizes = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2.5 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
}
const colors = colorMap[color]
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center font-semibold rounded-lg',
'transition-all duration-300 ease-out',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-900',
color === 'neon' && 'focus:ring-neon-500',
color === 'purple' && 'focus:ring-accent-500',
color === 'pink' && 'focus:ring-pink-500',
colors[variant],
sizeClasses[size],
className
)}
style={{
boxShadow: glow && !disabled && variant !== 'ghost' ? colors.glow : undefined,
}}
onMouseEnter={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = colors.glowHover
}
props.onMouseEnter?.(e)
}}
onMouseLeave={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = colors.glow
}
props.onMouseLeave?.(e)
}}
{...props}
>
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
{!isLoading && icon && iconPosition === 'left' && icon}
{children}
{!isLoading && icon && iconPosition === 'right' && icon}
</button>
)
}
)
NeonButton.displayName = 'NeonButton'

View File

@@ -0,0 +1,134 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #14161e;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #252732;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #2e313d;
}
/* Glass effect */
.glass {
background: rgba(20, 22, 30, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(34, 211, 238, 0.1);
}
.glass-dark {
background: rgba(13, 14, 20, 0.9);
backdrop-filter: blur(16px);
border: 1px solid rgba(34, 211, 238, 0.08);
}
.glass-neon {
background: rgba(20, 22, 30, 0.85);
backdrop-filter: blur(12px);
border: 1px solid rgba(34, 211, 238, 0.2);
box-shadow: 0 0 20px rgba(34, 211, 238, 0.08);
}
/* Neon glow effect */
.neon-glow {
box-shadow: 0 0 8px rgba(34, 211, 238, 0.4), 0 0 16px rgba(34, 211, 238, 0.2);
}
.neon-glow-purple {
box-shadow: 0 0 8px rgba(139, 92, 246, 0.4), 0 0 16px rgba(139, 92, 246, 0.2);
}
.neon-glow-pulse {
animation: glow-pulse 2s ease-in-out infinite;
}
/* Gradient border */
.gradient-border {
position: relative;
background: #14161e;
}
.gradient-border::before {
content: '';
position: absolute;
inset: -1px;
padding: 1px;
border-radius: inherit;
background: linear-gradient(135deg, #22d3ee, #8b5cf6, #f472b6);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0.5;
}
/* Card hover effect */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-2px);
border-color: rgba(34, 211, 238, 0.3);
box-shadow: 0 4px 20px rgba(34, 211, 238, 0.1);
}
/* Title bar drag region */
.titlebar {
-webkit-app-region: drag;
}
.titlebar button {
-webkit-app-region: no-drag;
}
/* Live indicator */
.live-indicator {
width: 8px;
height: 8px;
background: #22c55e;
border-radius: 50%;
animation: pulse-live 2s infinite;
}
@keyframes pulse-live {
0% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
/* Input focus styles */
input:focus {
outline: none;
border-color: rgba(34, 211, 238, 0.5);
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.1);
}
/* Selection */
::selection {
background: rgba(34, 211, 238, 0.3);
}
/* Transition utilities */
.transition-all-300 {
transition: all 0.3s ease;
}

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:* https://*; img-src 'self' data: https:">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<title>Game Marathon Tracker</title>
</head>
<body class="bg-dark-900 text-white antialiased">
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,491 @@
import { useEffect, useRef, useCallback, useState } from 'react'
import { Link } from 'react-router-dom'
import { Clock, Gamepad2, Plus, Trophy, Target, Loader2, ChevronDown, Timer, Play, Square } from 'lucide-react'
import { useTrackingStore } from '../store/tracking'
import { useAuthStore } from '../store/auth'
import { useMarathonStore } from '../store/marathon'
import { GlassCard } from '../components/ui/GlassCard'
import { NeonButton } from '../components/ui/NeonButton'
function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
return `${hours}ч ${remainingMinutes}м`
} else if (minutes > 0) {
return `${minutes}м`
} else {
return `${seconds}с`
}
}
function formatMinutes(minutes: number): string {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
if (hours > 0) {
return `${hours}ч ${mins}м`
}
return `${mins}м`
}
function getDifficultyColor(difficulty: string): string {
switch (difficulty) {
case 'easy': return 'text-green-400'
case 'medium': return 'text-yellow-400'
case 'hard': return 'text-red-400'
default: return 'text-gray-400'
}
}
function getDifficultyLabel(difficulty: string): string {
switch (difficulty) {
case 'easy': return 'Легкий'
case 'medium': return 'Средний'
case 'hard': return 'Сложный'
default: return difficulty
}
}
export function DashboardPage() {
const { user } = useAuthStore()
const { trackedGames, stats, currentGame, loadTrackedGames, updateStats } = useTrackingStore()
const {
marathons,
selectedMarathonId,
currentAssignment,
isLoading,
loadMarathons,
selectMarathon,
syncTime
} = useMarathonStore()
// Monitoring state
const [isMonitoring, setIsMonitoring] = useState(false)
const [localSessionSeconds, setLocalSessionSeconds] = useState(0)
// Refs for time tracking sync
const syncIntervalRef = useRef<NodeJS.Timeout | null>(null)
const lastSyncedMinutesRef = useRef<number>(0)
const sessionStartRef = useRef<number | null>(null)
// Check if we should track time: any tracked game is running + active assignment exists
const isTrackingAssignment = !!(currentGame && currentAssignment && currentAssignment.status === 'active')
// Track base minutes at session start to avoid re-adding on each sync
const baseMinutesRef = useRef<number>(0)
// Sync time to server - use refs to avoid dependency issues
const doSyncTime = useCallback(async () => {
const assignment = useMarathonStore.getState().currentAssignment
if (!assignment || assignment.status !== 'active' || !sessionStartRef.current) {
return
}
// Calculate session duration only
const sessionMinutes = Math.floor((Date.now() - sessionStartRef.current) / 60000)
const totalMinutes = baseMinutesRef.current + sessionMinutes
if (totalMinutes !== lastSyncedMinutesRef.current && totalMinutes > 0) {
console.log(`[Sync] Syncing ${totalMinutes} minutes for assignment ${assignment.id} (base: ${baseMinutesRef.current}, session: ${sessionMinutes})`)
await syncTime(totalMinutes)
lastSyncedMinutesRef.current = totalMinutes
}
}, [syncTime])
useEffect(() => {
loadTrackedGames()
loadMarathons()
// Load monitoring status
window.electronAPI.getMonitoringStatus().then(setIsMonitoring)
// Subscribe to tracking updates
const unsubscribe = window.electronAPI.onTrackingUpdate((newStats) => {
updateStats(newStats)
})
// Subscribe to game started event
const unsubGameStarted = window.electronAPI.onGameStarted((gameName, _gameId) => {
console.log(`[Game] Started: ${gameName}`)
sessionStartRef.current = Date.now()
setLocalSessionSeconds(0)
})
// Subscribe to game stopped event
const unsubGameStopped = window.electronAPI.onGameStopped((gameName, _duration) => {
console.log(`[Game] Stopped: ${gameName}`)
sessionStartRef.current = null
setLocalSessionSeconds(0)
})
// Get initial stats
window.electronAPI.getTrackingStats().then(updateStats)
return () => {
unsubscribe()
unsubGameStarted()
unsubGameStopped()
}
}, [loadTrackedGames, loadMarathons, updateStats])
// Setup sync interval and local timer when tracking
useEffect(() => {
let localTimerInterval: NodeJS.Timeout | null = null
if (isTrackingAssignment && currentAssignment) {
// Start session if not already started
if (!sessionStartRef.current) {
sessionStartRef.current = Date.now()
// Store base minutes at session start
baseMinutesRef.current = currentAssignment.tracked_time_minutes || 0
lastSyncedMinutesRef.current = baseMinutesRef.current
console.log(`[Sync] Session started, base minutes: ${baseMinutesRef.current}`)
}
// Setup periodic sync every 60 seconds (don't sync immediately to avoid loops)
syncIntervalRef.current = setInterval(() => {
doSyncTime()
}, 60000)
// Update local timer every second for UI
localTimerInterval = setInterval(() => {
if (sessionStartRef.current) {
setLocalSessionSeconds(Math.floor((Date.now() - sessionStartRef.current) / 1000))
}
}, 1000)
} else {
// Do final sync when game stops
if (sessionStartRef.current) {
doSyncTime()
}
if (syncIntervalRef.current) {
clearInterval(syncIntervalRef.current)
syncIntervalRef.current = null
}
sessionStartRef.current = null
baseMinutesRef.current = 0
setLocalSessionSeconds(0)
}
return () => {
if (syncIntervalRef.current) {
clearInterval(syncIntervalRef.current)
syncIntervalRef.current = null
}
if (localTimerInterval) {
clearInterval(localTimerInterval)
}
}
// Note: doSyncTime is intentionally excluded to avoid infinite loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTrackingAssignment])
// Toggle monitoring
const toggleMonitoring = async () => {
if (isMonitoring) {
await window.electronAPI.stopMonitoring()
setIsMonitoring(false)
} else {
await window.electronAPI.startMonitoring()
setIsMonitoring(true)
}
}
const todayTime = stats?.totalTimeToday || 0
const weekTime = stats?.totalTimeWeek || 0
const selectedMarathon = marathons.find(m => m.id === selectedMarathonId)
const renderCurrentChallenge = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
</div>
)
}
if (marathons.length === 0) {
return (
<p className="text-gray-400 text-sm">
Нет активных марафонов. Присоединитесь к марафону на сайте.
</p>
)
}
if (!currentAssignment) {
return (
<p className="text-gray-400 text-sm">
Нет активного задания. Крутите колесо на сайте!
</p>
)
}
const assignment = currentAssignment
// Playthrough assignment
if (assignment.is_playthrough && assignment.playthrough_info) {
// When actively tracking: use baseMinutesRef (set at session start) + current session
// Otherwise: use tracked_time_minutes from assignment
const baseMinutes = isTrackingAssignment ? baseMinutesRef.current : assignment.tracked_time_minutes
const sessionSeconds = isTrackingAssignment ? localSessionSeconds : 0
const totalSeconds = (baseMinutes * 60) + sessionSeconds
const totalMinutes = Math.floor(totalSeconds / 60)
const trackedHours = totalMinutes / 60
const estimatedPoints = Math.floor(trackedHours * 30)
// Format with seconds when actively tracking
const formatLiveTime = () => {
if (isTrackingAssignment && sessionSeconds > 0) {
const hours = Math.floor(totalSeconds / 3600)
const mins = Math.floor((totalSeconds % 3600) / 60)
const secs = totalSeconds % 60
if (hours > 0) {
return `${hours}ч ${mins}м ${secs}с`
}
return `${mins}м ${secs}с`
}
return formatMinutes(totalMinutes)
}
return (
<div>
<div className="flex items-start gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-white">
Прохождение: {assignment.game.title}
</h3>
{isTrackingAssignment && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-green-500/20 border border-green-500/30 rounded-full text-xs text-green-400">
<div className="live-indicator" />
Идёт запись
</span>
)}
</div>
{assignment.playthrough_info.description && (
<p className="text-sm text-gray-400 mb-2 line-clamp-2">
{assignment.playthrough_info.description}
</p>
)}
<div className="flex items-center gap-3 text-xs flex-wrap">
{totalSeconds > 0 || isTrackingAssignment ? (
<>
<span className="flex items-center gap-1 text-neon-400">
<Timer className="w-3 h-3" />
{formatLiveTime()}
</span>
<span className="text-neon-400 font-medium">
~{estimatedPoints} очков
</span>
</>
) : (
<span className="text-gray-500">
Базово: {assignment.playthrough_info.points} очков
</span>
)}
</div>
</div>
</div>
</div>
)
}
// Challenge assignment
if (assignment.challenge) {
const challenge = assignment.challenge
return (
<div>
<div className="flex items-start gap-3">
<div className="flex-1">
<h3 className="font-medium text-white mb-1">{challenge.title}</h3>
<p className="text-xs text-gray-500 mb-1">{challenge.game.title}</p>
<p className="text-sm text-gray-400 mb-2 line-clamp-2">
{challenge.description}
</p>
<div className="flex items-center gap-3 text-xs">
<span className={getDifficultyColor(challenge.difficulty)}>
[{getDifficultyLabel(challenge.difficulty)}]
</span>
<span className="text-neon-400 font-medium">
+{challenge.points} очков
</span>
{challenge.estimated_time && (
<span className="text-gray-500">
~{challenge.estimated_time} мин
</span>
)}
</div>
</div>
</div>
</div>
)
}
return (
<p className="text-gray-400 text-sm">
Задание загружается...
</p>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-display font-bold text-white">
Привет, {user?.nickname || 'Игрок'}!
</h1>
<p className="text-sm text-gray-400">
{isMonitoring ? (currentGame ? `Играет: ${currentGame}` : 'Мониторинг активен') : 'Мониторинг выключен'}
</p>
</div>
<div className="flex items-center gap-2">
{currentGame && isMonitoring && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-500/10 border border-green-500/30 rounded-full">
<div className="live-indicator" />
<span className="text-xs text-green-400 font-medium truncate max-w-[100px]">{currentGame}</span>
</div>
)}
<button
onClick={toggleMonitoring}
className={`p-2 rounded-lg transition-colors ${
isMonitoring
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
: 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
}`}
title={isMonitoring ? 'Остановить мониторинг' : 'Начать мониторинг'}
>
{isMonitoring ? <Square className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
</div>
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 gap-3">
<GlassCard variant="neon" className="p-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-neon-500/10 flex items-center justify-center">
<Clock className="w-5 h-5 text-neon-500" />
</div>
<div>
<p className="text-xs text-gray-400">Сегодня</p>
<p className="text-lg font-bold text-white">{formatTime(todayTime)}</p>
</div>
</div>
</GlassCard>
<GlassCard variant="default" className="p-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center">
<Trophy className="w-5 h-5 text-accent-500" />
</div>
<div>
<p className="text-xs text-gray-400">За неделю</p>
<p className="text-lg font-bold text-white">{formatTime(weekTime)}</p>
</div>
</div>
</GlassCard>
</div>
{/* Current challenge */}
<GlassCard variant="dark" className="border border-neon-500/20">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<Target className="w-5 h-5 text-neon-500" />
<h2 className="font-semibold text-white">Текущий челлендж</h2>
</div>
{/* Marathon selector */}
{marathons.length > 1 && (
<div className="relative">
<select
value={selectedMarathonId || ''}
onChange={(e) => selectMarathon(Number(e.target.value))}
className="appearance-none bg-dark-800 border border-dark-600 rounded-lg px-3 py-1.5 pr-8 text-xs text-gray-300 focus:outline-none focus:border-neon-500 cursor-pointer"
>
{marathons.map(m => (
<option key={m.id} value={m.id}>
{m.title.length > 30 ? m.title.substring(0, 30) + '...' : m.title}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 pointer-events-none" />
</div>
)}
</div>
{/* Marathon title for single marathon */}
{marathons.length === 1 && selectedMarathon && (
<p className="text-xs text-gray-500 mb-2">{selectedMarathon.title}</p>
)}
{renderCurrentChallenge()}
</GlassCard>
{/* Tracked games */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-white flex items-center gap-2">
<Gamepad2 className="w-5 h-5 text-neon-500" />
Отслеживаемые игры
</h2>
<Link to="/games">
<NeonButton variant="ghost" size="sm" icon={<Plus className="w-4 h-4" />}>
Добавить
</NeonButton>
</Link>
</div>
{trackedGames.length === 0 ? (
<GlassCard variant="dark" className="text-center py-8">
<Gamepad2 className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400 text-sm mb-4">
Нет отслеживаемых игр
</p>
<Link to="/games">
<NeonButton variant="secondary" size="sm">
Добавить игру
</NeonButton>
</Link>
</GlassCard>
) : (
<div className="grid grid-cols-2 gap-2">
{trackedGames.slice(0, 4).map((game) => (
<GlassCard
key={game.id}
variant="default"
hover
className="p-3"
>
<div className="flex items-center gap-2 mb-2">
{currentGame === game.name && <div className="live-indicator" />}
<p className="text-sm font-medium text-white truncate flex-1">
{game.name}
</p>
</div>
<p className="text-xs text-gray-400">
{formatTime(game.totalTime)}
</p>
</GlassCard>
))}
</div>
)}
{trackedGames.length > 4 && (
<Link to="/games" className="block mt-2">
<NeonButton variant="ghost" size="sm" className="w-full">
Показать все ({trackedGames.length})
</NeonButton>
</Link>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,298 @@
import { useEffect, useState } from 'react'
import { Gamepad2, Plus, Trash2, Search, FolderOpen, Cpu, RefreshCw, Loader2 } from 'lucide-react'
import { useTrackingStore } from '../store/tracking'
import { GlassCard } from '../components/ui/GlassCard'
import { NeonButton } from '../components/ui/NeonButton'
import { Input } from '../components/ui/Input'
import type { TrackedProcess } from '@shared/types'
function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) {
const remainingMinutes = minutes % 60
return `${hours}ч ${remainingMinutes}м`
} else if (minutes > 0) {
return `${minutes}м`
} else {
return `${seconds}с`
}
}
// System processes to filter out
const SYSTEM_PROCESSES = new Set([
'svchost', 'csrss', 'wininit', 'services', 'lsass', 'smss', 'winlogon',
'dwm', 'explorer', 'taskhost', 'conhost', 'spoolsv', 'searchhost',
'runtimebroker', 'sihost', 'fontdrvhost', 'ctfmon', 'dllhost',
'securityhealthservice', 'searchindexer', 'audiodg', 'wudfhost',
'system', 'registry', 'idle', 'memory compression', 'ntoskrnl',
'shellexperiencehost', 'startmenuexperiencehost', 'applicationframehost',
'systemsettings', 'textinputhost', 'searchui', 'cortana', 'lockapp',
'windowsinternal', 'taskhostw', 'wmiprvse', 'msiexec', 'trustedinstaller',
'tiworker', 'smartscreen', 'securityhealthsystray', 'sgrmbroker',
'gamebarpresencewriter', 'gamebar', 'gamebarftserver',
'microsoftedge', 'msedge', 'chrome', 'firefox', 'opera', 'brave',
'discord', 'slack', 'teams', 'zoom', 'skype',
'powershell', 'cmd', 'windowsterminal', 'code', 'devenv',
'node', 'npm', 'electron', 'vite'
])
export function GamesPage() {
const { trackedGames, currentGame, loadTrackedGames, addGame, removeGame } = useTrackingStore()
const [showAddModal, setShowAddModal] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [addMode, setAddMode] = useState<'process' | 'manual'>('process')
const [manualGame, setManualGame] = useState({ name: '', executableName: '' })
const [processes, setProcesses] = useState<TrackedProcess[]>([])
const [isLoadingProcesses, setIsLoadingProcesses] = useState(false)
useEffect(() => {
loadTrackedGames()
}, [loadTrackedGames])
const loadProcesses = async () => {
setIsLoadingProcesses(true)
try {
const procs = await window.electronAPI.getRunningProcesses()
// Filter out system processes and already tracked games
const filtered = procs.filter(p => {
const name = p.name.toLowerCase().replace('.exe', '')
return !SYSTEM_PROCESSES.has(name) &&
!trackedGames.some(tg =>
tg.executableName.toLowerCase().replace('.exe', '') === name
)
})
setProcesses(filtered)
} catch (error) {
console.error('Failed to load processes:', error)
} finally {
setIsLoadingProcesses(false)
}
}
useEffect(() => {
if (showAddModal && addMode === 'process') {
loadProcesses()
}
}, [showAddModal, addMode])
const filteredProcesses = processes.filter(
(proc) =>
proc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(proc.windowTitle && proc.windowTitle.toLowerCase().includes(searchQuery.toLowerCase()))
)
const handleAddProcess = async (process: TrackedProcess) => {
const name = process.windowTitle || process.displayName || process.name.replace('.exe', '')
await addGame({
id: `proc_${Date.now()}`,
name: name,
executableName: process.name,
executablePath: process.executablePath,
})
setShowAddModal(false)
setSearchQuery('')
}
const handleAddManualGame = async () => {
if (!manualGame.name || !manualGame.executableName) return
await addGame({
id: `manual_${Date.now()}`,
name: manualGame.name,
executableName: manualGame.executableName,
})
setShowAddModal(false)
setManualGame({ name: '', executableName: '' })
}
const handleRemoveGame = async (gameId: string) => {
if (confirm('Удалить игру из отслеживания?')) {
await removeGame(gameId)
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-display font-bold text-white flex items-center gap-2">
<Gamepad2 className="w-6 h-6 text-neon-500" />
Игры
</h1>
<NeonButton
size="sm"
icon={<Plus className="w-4 h-4" />}
onClick={() => setShowAddModal(true)}
>
Добавить
</NeonButton>
</div>
{/* Games list */}
{trackedGames.length === 0 ? (
<GlassCard variant="dark" className="text-center py-12">
<Gamepad2 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Нет игр</h3>
<p className="text-gray-400 text-sm mb-4">
Добавьте игры для отслеживания времени
</p>
<NeonButton onClick={() => setShowAddModal(true)}>
Добавить игру
</NeonButton>
</GlassCard>
) : (
<div className="space-y-2">
{trackedGames.map((game) => (
<GlassCard
key={game.id}
variant="default"
className="flex items-center justify-between"
>
<div className="flex items-center gap-3 min-w-0">
{currentGame === game.name && <div className="live-indicator flex-shrink-0" />}
<div className="min-w-0">
<p className="font-medium text-white truncate">{game.name}</p>
<p className="text-xs text-gray-400">
{formatTime(game.totalTime)} наиграно
{game.steamAppId && ' • Steam'}
</p>
</div>
</div>
<button
onClick={() => handleRemoveGame(game.id)}
className="p-2 text-gray-400 hover:text-red-400 transition-colors flex-shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</GlassCard>
))}
</div>
)}
{/* Add game modal */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/80 backdrop-blur-sm">
<GlassCard variant="dark" className="w-full max-w-sm mx-4 max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Добавить игру</h2>
<button
onClick={() => setShowAddModal(false)}
className="p-1 text-gray-400 hover:text-white"
>
</button>
</div>
{/* Mode tabs */}
<div className="flex gap-1 mb-4">
<button
onClick={() => setAddMode('process')}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
addMode === 'process'
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600'
}`}
>
<Cpu className="w-3.5 h-3.5" />
Процессы
</button>
<button
onClick={() => setAddMode('manual')}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
addMode === 'manual'
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600'
}`}
>
<FolderOpen className="w-3.5 h-3.5" />
Вручную
</button>
</div>
{addMode === 'process' && (
<>
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder="Поиск процесса..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
icon={<Search className="w-4 h-4" />}
/>
</div>
<button
onClick={loadProcesses}
disabled={isLoadingProcesses}
className="p-2.5 bg-dark-700 border border-dark-600 rounded-lg text-gray-400 hover:text-white hover:border-neon-500/50 transition-colors disabled:opacity-50"
title="Обновить список"
>
<RefreshCw className={`w-4 h-4 ${isLoadingProcesses ? 'animate-spin' : ''}`} />
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
Запустите игру и нажмите обновить
</p>
<div className="mt-3 space-y-2 overflow-y-auto max-h-52">
{isLoadingProcesses ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-neon-500 animate-spin" />
</div>
) : filteredProcesses.length === 0 ? (
<p className="text-center text-gray-400 text-sm py-4">
{processes.length === 0 ? 'Нет подходящих процессов' : 'Ничего не найдено'}
</p>
) : (
filteredProcesses.slice(0, 20).map((proc) => (
<button
key={proc.id}
onClick={() => handleAddProcess(proc)}
className="w-full flex items-start gap-3 p-2 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors text-left"
>
<Cpu className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<p className="text-sm text-white truncate">
{proc.windowTitle || proc.displayName || proc.name}
</p>
<p className="text-xs text-gray-500 truncate">
{proc.name}
</p>
</div>
</button>
))
)}
</div>
</>
)}
{addMode === 'manual' && (
<div className="space-y-4">
<Input
label="Название игры"
placeholder="Например: Elden Ring"
value={manualGame.name}
onChange={(e) => setManualGame({ ...manualGame, name: e.target.value })}
/>
<Input
label="Имя процесса (exe)"
placeholder="Например: eldenring.exe"
value={manualGame.executableName}
onChange={(e) => setManualGame({ ...manualGame, executableName: e.target.value })}
/>
<NeonButton
className="w-full"
onClick={handleAddManualGame}
disabled={!manualGame.name || !manualGame.executableName}
>
Добавить
</NeonButton>
</div>
)}
</GlassCard>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,183 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Gamepad2, User, Lock, X, Minus, Shield, ArrowLeft } from 'lucide-react'
import { useAuthStore } from '../store/auth'
import { NeonButton } from '../components/ui/NeonButton'
import { Input } from '../components/ui/Input'
export function LoginPage() {
const navigate = useNavigate()
const { login, verify2fa, isLoading, error, clearError, requires2fa, reset2fa } = useAuthStore()
const [formData, setFormData] = useState({
login: '',
password: '',
})
const [twoFactorCode, setTwoFactorCode] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const success = await login(formData.login, formData.password)
if (success) {
navigate('/')
}
}
const handle2faSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const success = await verify2fa(twoFactorCode)
if (success) {
navigate('/')
}
}
const handleBack = () => {
reset2fa()
setTwoFactorCode('')
}
return (
<div className="min-h-screen bg-dark-900 flex flex-col">
{/* Custom title bar */}
<div className="titlebar h-8 bg-dark-950 flex items-center justify-between px-2 border-b border-dark-700">
<div className="flex items-center gap-2">
<Gamepad2 className="w-4 h-4 text-neon-500" />
<span className="text-xs font-medium text-gray-400">Game Marathon Tracker</span>
</div>
<div className="flex items-center">
<button
onClick={() => window.electronAPI.minimizeToTray()}
className="w-8 h-8 flex items-center justify-center hover:bg-dark-700 transition-colors"
>
<Minus className="w-4 h-4 text-gray-400" />
</button>
<button
onClick={() => window.electronAPI.quitApp()}
className="w-8 h-8 flex items-center justify-center hover:bg-red-600 transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
</div>
{/* Login form */}
<div className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-neon-500/10 border border-neon-500/30 mb-4">
{requires2fa ? (
<Shield className="w-8 h-8 text-neon-500" />
) : (
<Gamepad2 className="w-8 h-8 text-neon-500" />
)}
</div>
<h1 className="text-2xl font-display font-bold text-white mb-2">
{requires2fa ? 'Подтверждение' : 'Game Marathon'}
</h1>
<p className="text-gray-400 text-sm">
{requires2fa
? 'Введите код из Telegram'
: 'Войдите в свой аккаунт'}
</p>
</div>
{requires2fa ? (
/* 2FA Form */
<form onSubmit={handle2faSubmit} className="space-y-4">
<Input
label="Код подтверждения"
type="text"
value={twoFactorCode}
onChange={(e) => {
setTwoFactorCode(e.target.value.replace(/\D/g, '').slice(0, 6))
clearError()
}}
icon={<Shield className="w-5 h-5" />}
placeholder="000000"
maxLength={6}
required
autoFocus
/>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
disabled={twoFactorCode.length !== 6}
>
Подтвердить
</NeonButton>
<button
type="button"
onClick={handleBack}
className="w-full flex items-center justify-center gap-2 text-gray-400 hover:text-white transition-colors py-2"
>
<ArrowLeft className="w-4 h-4" />
Назад
</button>
</form>
) : (
/* Login Form */
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Логин"
type="text"
value={formData.login}
onChange={(e) => {
setFormData({ ...formData, login: e.target.value })
clearError()
}}
icon={<User className="w-5 h-5" />}
placeholder="Введите логин"
required
/>
<Input
label="Пароль"
type="password"
value={formData.password}
onChange={(e) => {
setFormData({ ...formData, password: e.target.value })
clearError()
}}
icon={<Lock className="w-5 h-5" />}
placeholder="Введите пароль"
required
/>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
>
Войти
</NeonButton>
</form>
)}
{/* Footer */}
{!requires2fa && (
<p className="text-center text-gray-500 text-xs mt-6">
Нет аккаунта? Зарегистрируйтесь на сайте
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,267 @@
import { useEffect, useState } from 'react'
import { Settings, Power, Monitor, Clock, Globe, LogOut, Download, RefreshCw, Check, AlertCircle } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/auth'
import { GlassCard } from '../components/ui/GlassCard'
import { NeonButton } from '../components/ui/NeonButton'
import { Input } from '../components/ui/Input'
import type { AppSettings } from '@shared/types'
export function SettingsPage() {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [settings, setSettings] = useState<AppSettings | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [appVersion, setAppVersion] = useState('')
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'not-available' | 'error'>('idle')
const [updateVersion, setUpdateVersion] = useState('')
const [updateError, setUpdateError] = useState('')
useEffect(() => {
window.electronAPI.getSettings().then(setSettings)
window.electronAPI.getAppVersion().then(setAppVersion)
}, [])
const handleCheckForUpdates = async () => {
setUpdateStatus('checking')
setUpdateError('')
try {
const result = await window.electronAPI.checkForUpdates()
if (result.error) {
setUpdateStatus('error')
setUpdateError(result.error)
} else if (result.available) {
setUpdateStatus('available')
setUpdateVersion(result.version || '')
} else {
setUpdateStatus('not-available')
}
} catch (err) {
setUpdateStatus('error')
setUpdateError('Ошибка проверки')
}
}
const handleInstallUpdate = () => {
window.electronAPI.installUpdate()
}
const handleToggle = async (key: keyof AppSettings, value: boolean) => {
if (!settings) return
setIsSaving(true)
try {
await window.electronAPI.saveSettings({ [key]: value })
setSettings({ ...settings, [key]: value })
} finally {
setIsSaving(false)
}
}
const handleApiUrlChange = async (url: string) => {
if (!settings) return
setSettings({ ...settings, apiUrl: url })
}
const handleApiUrlSave = async () => {
if (!settings) return
setIsSaving(true)
try {
await window.electronAPI.saveSettings({ apiUrl: settings.apiUrl })
} finally {
setIsSaving(false)
}
}
const handleLogout = async () => {
await logout()
navigate('/login')
}
if (!settings) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-neon-500 border-t-transparent rounded-full" />
</div>
)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2">
<Settings className="w-6 h-6 text-neon-500" />
<h1 className="text-xl font-display font-bold text-white">Настройки</h1>
</div>
{/* User info */}
<GlassCard variant="neon" className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-neon-500/20 flex items-center justify-center">
<span className="text-lg font-bold text-neon-400">
{user?.nickname?.charAt(0).toUpperCase() || 'U'}
</span>
</div>
<div>
<p className="font-medium text-white">{user?.nickname}</p>
<p className="text-xs text-gray-400">@{user?.login}</p>
</div>
</div>
<NeonButton variant="ghost" size="sm" icon={<LogOut className="w-4 h-4" />} onClick={handleLogout}>
Выйти
</NeonButton>
</GlassCard>
{/* Settings */}
<div className="space-y-2">
{/* Auto-launch */}
<GlassCard variant="dark" className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center">
<Power className="w-5 h-5 text-accent-500" />
</div>
<div>
<p className="font-medium text-white">Автозапуск</p>
<p className="text-xs text-gray-400">Запускать при старте Windows</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.autoLaunch}
onChange={(e) => handleToggle('autoLaunch', e.target.checked)}
className="sr-only peer"
disabled={isSaving}
/>
<div className="w-11 h-6 bg-dark-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-neon-500"></div>
</label>
</GlassCard>
{/* Minimize to tray */}
<GlassCard variant="dark" className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-neon-500/10 flex items-center justify-center">
<Monitor className="w-5 h-5 text-neon-500" />
</div>
<div>
<p className="font-medium text-white">Сворачивать в трей</p>
<p className="text-xs text-gray-400">При закрытии скрывать в трей</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.minimizeToTray}
onChange={(e) => handleToggle('minimizeToTray', e.target.checked)}
className="sr-only peer"
disabled={isSaving}
/>
<div className="w-11 h-6 bg-dark-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-neon-500"></div>
</label>
</GlassCard>
{/* Tracking interval */}
<GlassCard variant="dark">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-pink-500/10 flex items-center justify-center">
<Clock className="w-5 h-5 text-pink-500" />
</div>
<div>
<p className="font-medium text-white">Интервал проверки</p>
<p className="text-xs text-gray-400">Как часто проверять процессы</p>
</div>
</div>
<select
value={settings.trackingInterval}
onChange={(e) => {
const value = Number(e.target.value)
setSettings({ ...settings, trackingInterval: value })
window.electronAPI.saveSettings({ trackingInterval: value })
}}
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-4 py-2 text-white"
>
<option value={3000}>3 секунды</option>
<option value={5000}>5 секунд</option>
<option value={10000}>10 секунд</option>
<option value={30000}>30 секунд</option>
</select>
</GlassCard>
{/* Updates */}
<GlassCard variant="dark">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
<Download className="w-5 h-5 text-green-500" />
</div>
<div>
<p className="font-medium text-white">Обновления</p>
<p className="text-xs text-gray-400">
{updateStatus === 'checking' && 'Проверка...'}
{updateStatus === 'available' && `Доступна v${updateVersion}`}
{updateStatus === 'not-available' && 'Актуальная версия'}
{updateStatus === 'error' && (updateError || 'Ошибка')}
{updateStatus === 'idle' && `Текущая версия: v${appVersion}`}
</p>
</div>
</div>
{updateStatus === 'available' ? (
<NeonButton size="sm" onClick={handleInstallUpdate}>
Установить
</NeonButton>
) : (
<button
onClick={handleCheckForUpdates}
disabled={updateStatus === 'checking'}
className="p-2 rounded-lg bg-dark-700 text-gray-400 hover:text-white hover:bg-dark-600 transition-colors disabled:opacity-50"
>
{updateStatus === 'checking' ? (
<RefreshCw className="w-5 h-5 animate-spin" />
) : updateStatus === 'not-available' ? (
<Check className="w-5 h-5 text-green-500" />
) : updateStatus === 'error' ? (
<AlertCircle className="w-5 h-5 text-red-500" />
) : (
<RefreshCw className="w-5 h-5" />
)}
</button>
)}
</div>
</GlassCard>
{/* API URL (for developers) */}
<GlassCard variant="dark">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-lg bg-gray-500/10 flex items-center justify-center">
<Globe className="w-5 h-5 text-gray-400" />
</div>
<div>
<p className="font-medium text-white">API URL</p>
<p className="text-xs text-gray-400">Для разработки</p>
</div>
</div>
<div className="flex gap-2">
<Input
value={settings.apiUrl}
onChange={(e) => handleApiUrlChange(e.target.value)}
placeholder="http://localhost:8000/api/v1"
className="flex-1"
/>
<NeonButton
variant="secondary"
size="sm"
onClick={handleApiUrlSave}
disabled={isSaving}
>
Сохранить
</NeonButton>
</div>
</GlassCard>
</div>
{/* Version */}
<p className="text-center text-gray-500 text-xs pt-4">
Game Marathon Tracker v{appVersion || '...'}
</p>
</div>
)
}

View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Game Marathon Tracker</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0d0e14;
color: white;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
-webkit-app-region: drag;
}
.logo-img {
width: 120px;
height: 120px;
margin-bottom: 20px;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); filter: drop-shadow(0 0 10px rgba(34, 211, 238, 0.5)); }
50% { transform: scale(1.05); filter: drop-shadow(0 0 20px rgba(139, 92, 246, 0.7)); }
}
.status {
font-size: 13px;
color: #9ca3af;
text-align: center;
min-height: 20px;
margin-top: 10px;
}
.progress-container {
width: 200px;
height: 4px;
background: rgba(34, 211, 238, 0.1);
border-radius: 2px;
margin-top: 15px;
overflow: hidden;
display: none;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #22d3ee, #8b5cf6);
border-radius: 2px;
width: 0%;
transition: width 0.3s ease;
}
.version {
position: absolute;
bottom: 15px;
font-size: 11px;
color: #6b7280;
}
</style>
</head>
<body>
<img src="logo.jpg" alt="Logo" class="logo-img">
<div class="status" id="status">Проверка обновлений...</div>
<div class="progress-container" id="progressContainer">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="version" id="version"></div>
<script>
const { ipcRenderer } = require('electron');
// Get current version
ipcRenderer.invoke('get-app-version').then(version => {
document.getElementById('version').textContent = `v${version}`;
});
// Listen for status updates
ipcRenderer.on('update-status', (event, status) => {
document.getElementById('status').textContent = status;
});
// Listen for download progress
ipcRenderer.on('update-progress', (event, percent) => {
const container = document.getElementById('progressContainer');
const bar = document.getElementById('progressBar');
container.style.display = 'block';
bar.style.width = `${percent}%`;
});
</script>
</body>
</html>

View File

@@ -0,0 +1,149 @@
import { create } from 'zustand'
import type { User } from '@shared/types'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
// 2FA state
requires2fa: boolean
twoFactorSessionId: number | null
login: (login: string, password: string) => Promise<boolean>
verify2fa: (code: string) => Promise<boolean>
logout: () => Promise<void>
syncUser: () => Promise<void>
clearError: () => void
reset2fa: () => void
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: true,
error: null,
requires2fa: false,
twoFactorSessionId: null,
login: async (login: string, password: string) => {
set({ isLoading: true, error: null, requires2fa: false, twoFactorSessionId: null })
const result = await window.electronAPI.apiLogin(login, password)
if (!result.success) {
set({
isLoading: false,
error: typeof result.error === 'string' ? result.error : 'Ошибка авторизации',
})
return false
}
const response = result.data!
if (response.requires_2fa && response.two_factor_session_id) {
set({
isLoading: false,
requires2fa: true,
twoFactorSessionId: response.two_factor_session_id,
})
return false // Not fully logged in yet
}
if (response.access_token && response.user) {
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
})
return true
}
set({ isLoading: false })
return false
},
verify2fa: async (code: string) => {
const sessionId = get().twoFactorSessionId
if (!sessionId) {
set({ error: 'Нет активной сессии 2FA' })
return false
}
set({ isLoading: true, error: null })
const result = await window.electronAPI.api2faVerify(sessionId, code)
if (!result.success) {
set({
isLoading: false,
error: typeof result.error === 'string' ? result.error : 'Неверный код',
})
return false
}
const response = result.data!
if (response.access_token && response.user) {
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
requires2fa: false,
twoFactorSessionId: null,
})
return true
}
set({ isLoading: false })
return false
},
reset2fa: () => set({ requires2fa: false, twoFactorSessionId: null, error: null }),
logout: async () => {
await window.electronAPI.clearToken()
set({
user: null,
token: null,
isAuthenticated: false,
error: null,
})
},
syncUser: async () => {
set({ isLoading: true })
const token = await window.electronAPI.getToken()
if (!token) {
set({ isLoading: false, isAuthenticated: false })
return
}
const result = await window.electronAPI.apiGetMe()
if (!result.success) {
await window.electronAPI.clearToken()
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
})
return
}
set({
user: result.data!,
token,
isAuthenticated: true,
isLoading: false,
})
},
clearError: () => set({ error: null }),
}))

View File

@@ -0,0 +1,123 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { MarathonResponse, AssignmentResponse } from '@shared/types'
interface MarathonState {
marathons: MarathonResponse[]
selectedMarathonId: number | null
currentAssignment: AssignmentResponse | null
isLoading: boolean
error: string | null
loadMarathons: () => Promise<void>
selectMarathon: (marathonId: number) => Promise<void>
loadCurrentAssignment: () => Promise<void>
syncTime: (minutes: number) => Promise<void>
reset: () => void
}
export const useMarathonStore = create<MarathonState>()(
persist(
(set, get) => ({
marathons: [],
selectedMarathonId: null,
currentAssignment: null,
isLoading: false,
error: null,
loadMarathons: async () => {
set({ isLoading: true, error: null })
const result = await window.electronAPI.apiRequest<MarathonResponse[]>('GET', '/marathons')
if (!result.success) {
set({ isLoading: false, error: result.error || 'Failed to load marathons' })
return
}
const marathons = result.data || []
const activeMarathons = marathons.filter(m => m.status === 'active')
set({ marathons: activeMarathons, isLoading: false })
// If we have a selected marathon, verify it's still valid
const { selectedMarathonId } = get()
if (selectedMarathonId) {
const stillExists = activeMarathons.some(m => m.id === selectedMarathonId)
if (!stillExists && activeMarathons.length > 0) {
// Select first available marathon
await get().selectMarathon(activeMarathons[0].id)
} else if (stillExists) {
// Reload assignment for current selection
await get().loadCurrentAssignment()
}
} else if (activeMarathons.length > 0) {
// No selection, select first marathon
await get().selectMarathon(activeMarathons[0].id)
}
},
selectMarathon: async (marathonId: number) => {
set({ selectedMarathonId: marathonId, currentAssignment: null })
await get().loadCurrentAssignment()
},
loadCurrentAssignment: async () => {
const { selectedMarathonId } = get()
if (!selectedMarathonId) {
set({ currentAssignment: null })
return
}
const result = await window.electronAPI.apiRequest<AssignmentResponse | null>(
'GET',
`/marathons/${selectedMarathonId}/current-assignment`
)
if (result.success) {
set({ currentAssignment: result.data ?? null })
} else {
// User might not be participant of this marathon
set({ currentAssignment: null, error: result.error })
}
},
syncTime: async (minutes: number) => {
const { currentAssignment } = get()
if (!currentAssignment || currentAssignment.status !== 'active') {
return
}
const result = await window.electronAPI.apiRequest(
'PATCH',
`/assignments/${currentAssignment.id}/track-time`,
{ minutes }
)
if (result.success) {
// Update local assignment with new tracked time
set({
currentAssignment: {
...currentAssignment,
tracked_time_minutes: minutes
}
})
}
},
reset: () => {
set({
marathons: [],
selectedMarathonId: null,
currentAssignment: null,
isLoading: false,
error: null
})
}
}),
{
name: 'marathon-storage',
partialize: (state) => ({ selectedMarathonId: state.selectedMarathonId })
}
)
)

View File

@@ -0,0 +1,81 @@
import { create } from 'zustand'
import type { TrackedGame, TrackingStats, SteamGame } from '@shared/types'
interface TrackingState {
trackedGames: TrackedGame[]
steamGames: SteamGame[]
stats: TrackingStats | null
currentGame: string | null
isLoading: boolean
loadTrackedGames: () => Promise<void>
loadSteamGames: () => Promise<void>
addGame: (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => Promise<void>
removeGame: (gameId: string) => Promise<void>
updateStats: (stats: TrackingStats) => void
setCurrentGame: (gameName: string | null) => void
}
export const useTrackingStore = create<TrackingState>((set) => ({
trackedGames: [],
steamGames: [],
stats: null,
currentGame: null,
isLoading: false,
loadTrackedGames: async () => {
set({ isLoading: true })
try {
const games = await window.electronAPI.getTrackedGames()
set({ trackedGames: games, isLoading: false })
} catch (error) {
console.error('Failed to load tracked games:', error)
set({ isLoading: false })
}
},
loadSteamGames: async () => {
try {
const games = await window.electronAPI.getSteamGames()
set({ steamGames: games })
} catch (error) {
console.error('Failed to load Steam games:', error)
}
},
addGame: async (game) => {
try {
const newGame = await window.electronAPI.addTrackedGame(game)
set((state) => ({
trackedGames: [...state.trackedGames, newGame],
}))
} catch (error) {
console.error('Failed to add game:', error)
throw error
}
},
removeGame: async (gameId) => {
try {
await window.electronAPI.removeTrackedGame(gameId)
set((state) => ({
trackedGames: state.trackedGames.filter((g) => g.id !== gameId),
}))
} catch (error) {
console.error('Failed to remove game:', error)
throw error
}
},
updateStats: (stats) => {
if (stats.currentGame) {
console.log('[Tracking] Current game:', stats.currentGame, 'Session:', Math.floor((stats.currentSessionDuration || 0) / 1000), 's')
}
set({
stats,
currentGame: stats.currentGame || null
})
},
setCurrentGame: (gameName) => set({ currentGame: gameName }),
}))

54
desktop/src/renderer/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,54 @@
/// <reference types="vite/client" />
import type { AppSettings, TrackedProcess, SteamGame, TrackedGame, TrackingStats, User, LoginResponse } from '@shared/types'
interface ApiResult<T> {
success: boolean
data?: T
error?: string
status?: number
}
declare global {
interface Window {
electronAPI: {
// Settings
getSettings: () => Promise<AppSettings>
saveSettings: (settings: Partial<AppSettings>) => Promise<void>
// Auth (local storage)
getToken: () => Promise<string | null>
saveToken: (token: string) => Promise<void>
clearToken: () => Promise<void>
// API calls (through main process - no CORS)
apiLogin: (login: string, password: string) => Promise<ApiResult<LoginResponse>>
api2faVerify: (sessionId: number, code: string) => Promise<ApiResult<LoginResponse>>
apiGetMe: () => Promise<ApiResult<User>>
apiRequest: <T>(method: string, endpoint: string, data?: unknown) => Promise<ApiResult<T>>
// Process tracking
getRunningProcesses: () => Promise<TrackedProcess[]>
getForegroundWindow: () => Promise<string | null>
getTrackingStats: () => Promise<TrackingStats>
// Steam
getSteamGames: () => Promise<SteamGame[]>
getSteamPath: () => Promise<string | null>
// Tracked games
getTrackedGames: () => Promise<TrackedGame[]>
addTrackedGame: (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => Promise<TrackedGame>
removeTrackedGame: (gameId: string) => Promise<void>
// Window controls
minimizeToTray: () => void
quitApp: () => void
// Events
onTrackingUpdate: (callback: (stats: TrackingStats) => void) => () => void
onGameStarted: (callback: (gameName: string) => void) => () => void
onGameStopped: (callback: (gameName: string, duration: number) => void) => () => void
}
}
}

226
desktop/src/shared/types.ts Normal file
View File

@@ -0,0 +1,226 @@
// Shared types between main and renderer processes
export interface User {
id: number
login: string
nickname: string
avatar_url?: string
role: 'USER' | 'ADMIN'
}
export interface TokenResponse {
access_token: string
token_type: string
user: User
}
export interface LoginResponse {
access_token?: string
token_type?: string
user?: User
requires_2fa?: boolean
two_factor_session_id?: number
}
// API Response types
export interface MarathonResponse {
id: number
title: string
description?: string
status: 'preparing' | 'active' | 'finished'
start_date?: string
end_date?: string
cover_url?: string
is_public: boolean
participation?: {
is_organizer: boolean
points: number
completed_count: number
dropped_count: number
}
}
export interface GameShort {
id: number
title: string
cover_url?: string
download_url?: string
game_type?: 'challenges' | 'playthrough'
}
export interface ChallengeResponse {
id: number
title: string
description: string
type: string
difficulty: 'easy' | 'medium' | 'hard'
points: number
estimated_time?: number
proof_type: string
proof_hint?: string
game: GameShort
is_generated: boolean
}
export interface PlaythroughInfo {
description?: string
points: number
proof_type: string
proof_hint?: string
}
export interface AssignmentResponse {
id: number
challenge?: ChallengeResponse
game: GameShort
is_playthrough: boolean
playthrough_info?: PlaythroughInfo
status: 'active' | 'completed' | 'dropped' | 'returned'
proof_url?: string
proof_comment?: string
points_earned: number
tracked_time_minutes: number
started_at: string
completed_at?: string
can_drop: boolean
drop_penalty: number
event_type?: string
}
export interface Marathon {
id: number
title: string
description?: string
status: 'lobby' | 'active' | 'finished'
start_date: string
end_date?: string
cover_url?: string
}
export interface Game {
id: number
name: string
genre?: string
cover_url?: string
marathon_id: number
}
export interface Challenge {
id: number
game_id: number
title: string
description: string
type: string
difficulty: 'easy' | 'medium' | 'hard'
points: number
estimated_time: number
proof_type: string
proof_hint?: string
}
export interface Assignment {
id: number
participant_id: number
game_id: number
challenge_id?: number
game: Game
challenge?: Challenge
status: 'active' | 'completed' | 'dropped'
started_at: string
completed_at?: string
time_spent_minutes?: number
}
export interface CurrentChallenge {
marathon: Marathon
assignment?: Assignment
}
// Process tracking types
export interface TrackedProcess {
id: string
name: string
displayName: string
executablePath?: string
windowTitle?: string
isGame: boolean
steamAppId?: string
}
export interface GameSession {
gameId: string
gameName: string
startTime: number
endTime?: number
duration: number
isActive: boolean
}
export interface TrackingStats {
totalTimeToday: number
totalTimeWeek: number
totalTimeMonth: number
sessions: GameSession[]
currentGame?: string | null
currentSessionDuration?: number
}
export interface SteamGame {
appId: string
name: string
installDir: string
executable?: string
iconPath?: string
}
export interface TrackedGame {
id: string
name: string
executableName: string
executablePath?: string
steamAppId?: string
iconPath?: string
totalTime: number
lastPlayed?: number
}
export interface AppSettings {
autoLaunch: boolean
minimizeToTray: boolean
trackingInterval: number
apiUrl: string
theme: 'dark'
}
// IPC Channel types
export interface IpcChannels {
// Settings
'get-settings': () => AppSettings
'save-settings': (settings: Partial<AppSettings>) => void
// Auth
'get-token': () => string | null
'save-token': (token: string) => void
'clear-token': () => void
// Process tracking
'get-running-processes': () => TrackedProcess[]
'get-foreground-window': () => string | null
'start-tracking': (processName: string) => void
'stop-tracking': () => void
'get-tracking-stats': () => TrackingStats
// Steam
'get-steam-games': () => SteamGame[]
'get-steam-path': () => string | null
// Tracked games
'get-tracked-games': () => TrackedGame[]
'add-tracked-game': (game: Omit<TrackedGame, 'totalTime' | 'lastPlayed'>) => TrackedGame
'remove-tracked-game': (gameId: string) => void
'update-game-time': (gameId: string, time: number) => void
// Window
'minimize-to-tray': () => void
'quit-app': () => void
}

147
desktop/tailwind.config.js Normal file
View File

@@ -0,0 +1,147 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/renderer/index.html",
"./src/renderer/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
dark: {
950: '#08090d',
900: '#0d0e14',
800: '#14161e',
700: '#1c1e28',
600: '#252732',
500: '#2e313d',
},
neon: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#67e8f9',
500: '#22d3ee',
600: '#06b6d4',
700: '#0891b2',
800: '#155e75',
900: '#164e63',
},
accent: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
pink: {
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
},
primary: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#67e8f9',
500: '#22d3ee',
600: '#06b6d4',
700: '#0891b2',
800: '#155e75',
900: '#164e63',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
display: ['Orbitron', 'sans-serif'],
},
animation: {
'spin-slow': 'spin 3s linear infinite',
'fade-in': 'fade-in 0.3s ease-out forwards',
'slide-up': 'slide-up 0.3s ease-out forwards',
'glow-pulse': 'glow-pulse 2s ease-in-out infinite',
'float': 'float 6s ease-in-out infinite',
'shimmer': 'shimmer 2s linear infinite',
'slide-in-up': 'slide-in-up 0.4s ease-out forwards',
'scale-in': 'scale-in 0.2s ease-out forwards',
'bounce-in': 'bounce-in 0.5s ease-out forwards',
'pulse-neon': 'pulse-neon 2s ease-in-out infinite',
},
keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'slide-up': {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'glow-pulse': {
'0%, 100%': {
boxShadow: '0 0 6px rgba(34, 211, 238, 0.4), 0 0 12px rgba(34, 211, 238, 0.2)'
},
'50%': {
boxShadow: '0 0 10px rgba(34, 211, 238, 0.5), 0 0 20px rgba(34, 211, 238, 0.3)'
},
},
'float': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' },
},
'shimmer': {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
'slide-in-up': {
'0%': { opacity: '0', transform: 'translateY(30px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'scale-in': {
'0%': { opacity: '0', transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
'bounce-in': {
'0%': { opacity: '0', transform: 'scale(0.3)' },
'50%': { transform: 'scale(1.05)' },
'70%': { transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
'pulse-neon': {
'0%, 100%': {
textShadow: '0 0 6px rgba(34, 211, 238, 0.5), 0 0 12px rgba(34, 211, 238, 0.25)'
},
'50%': {
textShadow: '0 0 10px rgba(34, 211, 238, 0.6), 0 0 18px rgba(34, 211, 238, 0.35)'
},
},
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'neon-glow': 'linear-gradient(90deg, #22d3ee, #8b5cf6, #22d3ee)',
'cyber-grid': `
linear-gradient(rgba(34, 211, 238, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 211, 238, 0.02) 1px, transparent 1px)
`,
},
backgroundSize: {
'grid': '50px 50px',
},
boxShadow: {
'neon': '0 0 8px rgba(34, 211, 238, 0.4), 0 0 16px rgba(34, 211, 238, 0.2)',
'neon-lg': '0 0 12px rgba(34, 211, 238, 0.5), 0 0 24px rgba(34, 211, 238, 0.3)',
'neon-purple': '0 0 8px rgba(139, 92, 246, 0.4), 0 0 16px rgba(139, 92, 246, 0.2)',
'neon-pink': '0 0 8px rgba(244, 114, 182, 0.4), 0 0 16px rgba(244, 114, 182, 0.2)',
'inner-glow': 'inset 0 0 20px rgba(34, 211, 238, 0.06)',
'glass': '0 8px 32px 0 rgba(0, 0, 0, 0.37)',
},
},
},
plugins: [],
}

26
desktop/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"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/renderer/*"],
"@shared/*": ["src/shared/*"]
}
},
"include": ["src/renderer/**/*", "src/shared/**/*"],
"references": [{ "path": "./tsconfig.main.json" }]
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"outDir": "dist/main",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true
},
"include": ["src/main/**/*", "src/shared/**/*", "src/preload/**/*"]
}

22
desktop/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
root: 'src/renderer',
base: './',
build: {
outDir: '../../dist/renderer',
emptyOutDir: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/renderer'),
'@shared': path.resolve(__dirname, 'src/shared'),
},
},
server: {
port: 5173,
},
})

669
docs/tz-obs-widget.md Normal file
View File

@@ -0,0 +1,669 @@
# ТЗ: OBS Виджеты для стрима
## Описание задачи
Создать набор виджетов для отображения информации о марафоне в OBS через Browser Source. Виджеты позволяют стримерам показывать зрителям актуальную информацию о марафоне в реальном времени.
---
## Виджеты
### 1. Лидерборд
Таблица участников марафона с их позициями и очками.
| Поле | Описание |
|------|----------|
| Место | Позиция в рейтинге (1, 2, 3...) |
| Аватар | Аватарка участника (круглая, 32x32 px) |
| Никнейм | Имя участника |
| Очки | Текущее количество очков |
| Стрик | Текущий стрик (опционально) |
**Настройки:**
- Количество отображаемых участников (3, 5, 10, все)
- Подсветка текущего стримера
- Показ/скрытие аватарок
- Показ/скрытие стриков
---
### 2. Текущее задание
Отображает активное задание стримера.
| Поле | Описание |
|------|----------|
| Игра | Название игры |
| Задание | Описание челленджа / прохождения |
| Очки | Количество очков за выполнение |
| Тип | Челлендж / Прохождение |
| Прогресс бонусов | Для прохождений: X/Y бонусных челленджей |
**Состояния:**
- Активное задание — показывает детали
- Нет задания — "Ожидание спина" или скрыт
---
### 3. Прогресс марафона
Общая статистика стримера в марафоне.
| Поле | Описание |
|------|----------|
| Позиция | Текущее место в рейтинге |
| Очки | Набранные очки |
| Стрик | Текущий стрик |
| Выполнено | Количество выполненных заданий |
| Дропнуто | Количество дропнутых заданий |
---
### 4. Комбинированный виджет (опционально)
Объединяет несколько блоков в одном виджете:
- Мини-лидерборд (топ-3)
- Текущее задание
- Статистика стримера
---
## Техническая реализация
### Архитектура
```
┌─────────────────────────────────────────────────────────────────┐
│ OBS Browser Source │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ /widget/{type}?params │ │
│ │ │ │
│ │ Frontend страница │ │
│ │ (React / статический HTML)│ │
│ └─────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ WebSocket / Polling │ │
│ │ Обновление данных │ │
│ └─────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Backend API │ │
│ │ /api/v1/widget/* │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### URL структура
```
/widget/leaderboard?marathon={id}&token={token}&theme={theme}&count={count}
/widget/current?marathon={id}&token={token}&theme={theme}
/widget/progress?marathon={id}&token={token}&theme={theme}
/widget/combined?marathon={id}&token={token}&theme={theme}
```
### Параметры URL
| Параметр | Обязательный | Описание |
|----------|--------------|----------|
| `marathon` | Да | ID марафона |
| `token` | Да | Токен виджета (привязан к участнику) |
| `theme` | Нет | Тема оформления (dark, light, custom) |
| `count` | Нет | Количество участников (для лидерборда) |
| `highlight` | Нет | Подсветить пользователя (true/false) |
| `avatars` | Нет | Показывать аватарки (true/false, по умолчанию true) |
| `fontSize` | Нет | Размер шрифта (sm, md, lg) |
| `width` | Нет | Ширина виджета в пикселях |
| `transparent` | Нет | Прозрачный фон (true/false) |
---
## Backend API
### Токен виджета
Для авторизации виджетов используется специальный токен, привязанный к участнику марафона. Это позволяет:
- Идентифицировать стримера для подсветки в лидерборде
- Показывать личную статистику и задания
- Не требовать полной авторизации в OBS
#### Генерация токена
```
POST /api/v1/marathons/{marathon_id}/widget-token
Authorization: Bearer {jwt_token}
Response:
{
"token": "wgt_abc123xyz...",
"expires_at": null, // Бессрочный или с датой
"urls": {
"leaderboard": "https://marathon.example.com/widget/leaderboard?marathon=1&token=wgt_abc123xyz",
"current": "https://marathon.example.com/widget/current?marathon=1&token=wgt_abc123xyz",
"progress": "https://marathon.example.com/widget/progress?marathon=1&token=wgt_abc123xyz"
}
}
```
#### Модель токена
```python
class WidgetToken(Base):
__tablename__ = "widget_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id"))
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id"))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
participant: Mapped["Participant"] = relationship()
marathon: Mapped["Marathon"] = relationship()
```
### Эндпоинты виджетов
```python
# Публичные эндпоинты (авторизация через widget token)
@router.get("/widget/leaderboard")
async def widget_leaderboard(
marathon: int,
token: str,
count: int = 10,
db: DbSession
) -> WidgetLeaderboardResponse:
"""
Получить данные лидерборда для виджета.
Возвращает топ участников и позицию владельца токена.
"""
@router.get("/widget/current")
async def widget_current_assignment(
marathon: int,
token: str,
db: DbSession
) -> WidgetCurrentResponse:
"""
Получить текущее задание владельца токена.
"""
@router.get("/widget/progress")
async def widget_progress(
marathon: int,
token: str,
db: DbSession
) -> WidgetProgressResponse:
"""
Получить статистику владельца токена.
"""
```
### Схемы ответов
```python
class WidgetLeaderboardEntry(BaseModel):
rank: int
nickname: str
avatar_url: str | None
total_points: int
current_streak: int
is_current_user: bool # Для подсветки
class WidgetLeaderboardResponse(BaseModel):
entries: list[WidgetLeaderboardEntry]
current_user_rank: int | None
total_participants: int
marathon_title: str
class WidgetCurrentResponse(BaseModel):
has_assignment: bool
game_title: str | None
game_cover_url: str | None
assignment_type: str | None # "challenge" | "playthrough"
challenge_title: str | None
challenge_description: str | None
points: int | None
bonus_completed: int | None # Для прохождений
bonus_total: int | None
class WidgetProgressResponse(BaseModel):
nickname: str
avatar_url: str | None
rank: int
total_points: int
current_streak: int
completed_count: int
dropped_count: int
marathon_title: str
```
---
## Frontend
### Структура файлов
```
frontend/
├── src/
│ ├── pages/
│ │ └── widget/
│ │ ├── LeaderboardWidget.tsx
│ │ ├── CurrentWidget.tsx
│ │ ├── ProgressWidget.tsx
│ │ └── CombinedWidget.tsx
│ ├── components/
│ │ └── widget/
│ │ ├── WidgetContainer.tsx
│ │ ├── LeaderboardRow.tsx
│ │ ├── AssignmentCard.tsx
│ │ └── StatsBlock.tsx
│ └── styles/
│ └── widget/
│ ├── themes/
│ │ ├── dark.css
│ │ ├── light.css
│ │ └── neon.css
│ └── widget.css
```
### Роутинг
```tsx
// App.tsx или router config
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
<Route path="/widget/current" element={<CurrentWidget />} />
<Route path="/widget/progress" element={<ProgressWidget />} />
<Route path="/widget/combined" element={<CombinedWidget />} />
```
### Компонент виджета
```tsx
// pages/widget/LeaderboardWidget.tsx
import { useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { widgetApi } from '@/api/widget'
const LeaderboardWidget = () => {
const [params] = useSearchParams()
const marathon = params.get('marathon')
const token = params.get('token')
const theme = params.get('theme') || 'dark'
const count = parseInt(params.get('count') || '5')
const highlight = params.get('highlight') !== 'false'
const { data, isLoading } = useQuery({
queryKey: ['widget-leaderboard', marathon, token],
queryFn: () => widgetApi.getLeaderboard(marathon, token, count),
refetchInterval: 30000, // Обновление каждые 30 сек
})
if (isLoading) return <WidgetLoader />
if (!data) return null
return (
<WidgetContainer theme={theme} transparent={params.get('transparent') === 'true'}>
<div className="widget-leaderboard">
<h3 className="widget-title">{data.marathon_title}</h3>
{data.entries.map((entry) => (
<LeaderboardRow
key={entry.rank}
entry={entry}
highlight={highlight && entry.is_current_user}
/>
))}
</div>
</WidgetContainer>
)
}
```
---
## Темы оформления
### Базовые темы
#### Dark (по умолчанию)
```css
.widget-theme-dark {
--widget-bg: rgba(18, 18, 18, 0.95);
--widget-text: #ffffff;
--widget-text-secondary: #a0a0a0;
--widget-accent: #8b5cf6;
--widget-highlight: rgba(139, 92, 246, 0.2);
--widget-border: rgba(255, 255, 255, 0.1);
}
```
#### Light
```css
.widget-theme-light {
--widget-bg: rgba(255, 255, 255, 0.95);
--widget-text: #1a1a1a;
--widget-text-secondary: #666666;
--widget-accent: #7c3aed;
--widget-highlight: rgba(124, 58, 237, 0.1);
--widget-border: rgba(0, 0, 0, 0.1);
}
```
#### Neon
```css
.widget-theme-neon {
--widget-bg: rgba(0, 0, 0, 0.9);
--widget-text: #00ff88;
--widget-text-secondary: #00cc6a;
--widget-accent: #ff00ff;
--widget-highlight: rgba(255, 0, 255, 0.2);
--widget-border: #00ff88;
}
```
#### Transparent
```css
.widget-transparent {
--widget-bg: transparent;
}
```
### Кастомизация через URL
```
?theme=dark
?theme=light
?theme=neon
?theme=custom&bg=1a1a1a&text=ffffff&accent=ff6600
?transparent=true
```
---
## Обновление данных
### Варианты
| Способ | Описание | Плюсы | Минусы |
|--------|----------|-------|--------|
| Polling | Периодический запрос (30 сек) | Простота | Задержка, нагрузка |
| WebSocket | Реал-тайм обновления | Мгновенно | Сложность |
| SSE | Server-Sent Events | Простой real-time | Односторонний |
### Рекомендация
**Polling с интервалом 30 секунд** — оптимальный баланс:
- Простая реализация
- Минимальная нагрузка на сервер
- Достаточная актуальность для стрима
Для будущего развития можно добавить WebSocket.
---
## Интерфейс настройки
### Страница генерации виджетов
В личном кабинете участника добавить раздел "Виджеты для стрима":
```tsx
// pages/WidgetSettingsPage.tsx
const WidgetSettingsPage = () => {
const [widgetToken, setWidgetToken] = useState<string | null>(null)
const [selectedTheme, setSelectedTheme] = useState('dark')
const [leaderboardCount, setLeaderboardCount] = useState(5)
const generateToken = async () => {
const response = await api.createWidgetToken(marathonId)
setWidgetToken(response.token)
}
const widgetUrl = (type: string) => {
const params = new URLSearchParams({
marathon: marathonId.toString(),
token: widgetToken,
theme: selectedTheme,
...(type === 'leaderboard' && { count: leaderboardCount.toString() }),
})
return `${window.location.origin}/widget/${type}?${params}`
}
return (
<div>
<h1>Виджеты для OBS</h1>
{!widgetToken ? (
<Button onClick={generateToken}>Создать токен</Button>
) : (
<>
<Section title="Настройки">
<Select
label="Тема"
value={selectedTheme}
options={['dark', 'light', 'neon']}
onChange={setSelectedTheme}
/>
<Input
label="Участников в лидерборде"
type="number"
value={leaderboardCount}
onChange={setLeaderboardCount}
/>
</Section>
<Section title="Ссылки для OBS">
<WidgetUrlBlock
title="Лидерборд"
url={widgetUrl('leaderboard')}
preview={<LeaderboardPreview />}
/>
<WidgetUrlBlock
title="Текущее задание"
url={widgetUrl('current')}
preview={<CurrentPreview />}
/>
<WidgetUrlBlock
title="Прогресс"
url={widgetUrl('progress')}
/>
</Section>
<Section title="Инструкция">
<ol>
<li>Скопируйте нужную ссылку</li>
<li>В OBS добавьте источник "Browser"</li>
<li>Вставьте ссылку в поле URL</li>
<li>Установите размер (рекомендуется: 400x300)</li>
</ol>
</Section>
</>
)}
</div>
)
}
```
### Превью виджетов
Показывать живой превью виджета с текущими настройками:
```tsx
const WidgetPreview = ({ type, params }) => {
return (
<div className="widget-preview">
<iframe
src={`/widget/${type}?${params}`}
width="400"
height="300"
style={{ border: 'none', borderRadius: 8 }}
/>
</div>
)
}
```
---
## Безопасность
### Токены виджетов
- Токен привязан к конкретному участнику и марафону
- Токен можно отозвать (деактивировать)
- Токен даёт доступ только к публичной информации марафона
- Нельзя использовать для изменения данных
### Rate Limiting
```python
# Ограничения для widget эндпоинтов
WIDGET_RATE_LIMIT = "60/minute" # 60 запросов в минуту на токен
```
### Валидация токена
```python
async def validate_widget_token(token: str, marathon_id: int, db: AsyncSession) -> WidgetToken:
widget_token = await db.scalar(
select(WidgetToken)
.options(selectinload(WidgetToken.participant))
.where(
WidgetToken.token == token,
WidgetToken.marathon_id == marathon_id,
WidgetToken.is_active == True,
)
)
if not widget_token:
raise HTTPException(status_code=401, detail="Invalid widget token")
if widget_token.expires_at and widget_token.expires_at < datetime.utcnow():
raise HTTPException(status_code=401, detail="Widget token expired")
return widget_token
```
---
## План реализации
### Этап 1: Backend — модель и токены ✅
- [x] Создать модель `WidgetToken`
- [x] Миграция для таблицы `widget_tokens`
- [x] API создания токена (`POST /widgets/marathons/{id}/token`)
- [x] API отзыва токена (`DELETE /widgets/tokens/{id}`)
- [x] API регенерации токена (`POST /widgets/tokens/{id}/regenerate`)
- [x] Валидация токена
### Этап 2: Backend — API виджетов ✅
- [x] Эндпоинт `/widgets/data/leaderboard`
- [x] Эндпоинт `/widgets/data/current`
- [x] Эндпоинт `/widgets/data/progress`
- [x] Схемы ответов
- [ ] Rate limiting
### Этап 3: Frontend — страницы виджетов ✅
- [x] Роутинг `/widget/*`
- [x] Компонент `LeaderboardWidget`
- [x] Компонент `CurrentWidget`
- [x] Компонент `ProgressWidget`
- [x] Polling обновлений (30 сек)
### Этап 4: Frontend — темы и стили ✅
- [x] Базовые стили виджетов
- [x] Тема Dark
- [x] Тема Light
- [x] Тема Neon
- [x] Поддержка прозрачного фона
- [x] Параметры кастомизации через URL (theme, count, avatars, transparent)
### Этап 5: Frontend — страница настроек ✅
- [x] Модальное окно настройки виджетов (WidgetSettingsModal)
- [x] Форма настроек (тема, количество, аватарки, прозрачность)
- [x] Копирование URL
- [x] Превью виджетов (iframe)
- [x] Инструкция по добавлению в OBS
### Этап 6: Тестирование
- [ ] Проверка в OBS Browser Source
- [ ] Тестирование тем
- [ ] Проверка обновления данных
- [ ] Тестирование на разных разрешениях
- [ ] Проверка производительности (polling)
### Не реализовано (опционально)
- [x] Комбинированный виджет
- [ ] Rate limiting для API виджетов
---
## Примеры виджетов
### Лидерборд (Dark theme)
```
┌─────────────────────────────────────┐
│ 🏆 Game Marathon │
├─────────────────────────────────────┤
│ 1. 🟣 PlayerOne 1250 pts │
│ 2. 🔵 StreamerPro 980 pts │
│ ▶3. 🟢 CurrentUser 875 pts ◀│
│ 4. 🟡 GamerX 720 pts │
│ 5. 🔴 ProPlayer 650 pts │
└─────────────────────────────────────┘
аватарки
```
### Текущее задание
```
┌─────────────────────────────────┐
│ 🎮 Dark Souls III │
├─────────────────────────────────┤
│ Челлендж: │
│ Победить Намлесс Кинга │
│ без брони │
│ │
│ Очки: +150 │
│ │
│ Сложность: ⭐⭐⭐ │
└─────────────────────────────────┘
```
### Прогресс
```
┌─────────────────────────────────┐
│ 🟢 CurrentUser │
│ ↑ │
│ аватарка │
├─────────────────────────────────┤
│ Место: #3 │
│ Очки: 875 │
│ Стрик: 🔥 5 │
│ Выполнено: 12 │
│ Дропнуто: 2 │
└─────────────────────────────────┘
```
---
## Дополнительные идеи (будущее)
- **Анимации** — анимация при изменении позиций в лидерборде
- **Звуковые оповещения** — звук при выполнении задания
- **WebSocket** — мгновенные обновления без polling
- **Кастомный CSS** — возможность вставить свой CSS
- **Виджет событий** — показ активных событий марафона
- **Виджет колеса** — мини-версия колеса фортуны

View File

@@ -0,0 +1,789 @@
# ТЗ: Скип с изгнанием, модерация и выдача предметов
## Обзор
Три связанные фичи:
1. **Скип с изгнанием** — новый консамбл, который скипает задание И навсегда исключает игру из пула участника
2. **Модерация марафона** — организаторы могут скипать задания у участников (обычный скип / скип с изгнанием)
3. **Выдача предметов админами** — UI для системных администраторов для выдачи предметов пользователям
---
## 1. Скип с изгнанием (SKIP_EXILE)
### 1.1 Концепция
| Тип скипа | Штраф | Стрик | Игра может выпасть снова |
|-----------|-------|-------|--------------------------|
| Обычный DROP | Да (прогрессивный) | Сбрасывается | Да (для challenges) / Нет (для playthrough) |
| SKIP (консамбл) | Нет | Сохраняется | Да (для challenges) / Нет (для playthrough) |
| **SKIP_EXILE** | Нет | Сохраняется | **Нет** |
### 1.2 Backend
#### Новая модель: ExiledGame
```python
# backend/app/models/exiled_game.py
class ExiledGame(Base):
__tablename__ = "exiled_games"
__table_args__ = (
UniqueConstraint("participant_id", "game_id", name="unique_participant_game_exile"),
)
id: int (PK)
participant_id: int (FK -> participants.id, ondelete=CASCADE)
game_id: int (FK -> games.id, ondelete=CASCADE)
assignment_id: int | None (FK -> assignments.id) # Какое задание было при изгнании
exiled_at: datetime
exiled_by: str # "user" | "organizer" | "admin"
reason: str | None # Опциональная причина
# История восстановления (soft-delete pattern)
is_active: bool = True # False = игра возвращена в пул
unexiled_at: datetime | None
unexiled_by: str | None # "organizer" | "admin"
```
> **Примечание**: При восстановлении игры запись НЕ удаляется, а помечается `is_active=False`.
> Это сохраняет историю изгнаний для аналитики и разрешения споров.
#### Новый ConsumableType
```python
# backend/app/models/shop.py
class ConsumableType(str, Enum):
SKIP = "skip"
SKIP_EXILE = "skip_exile" # NEW
BOOST = "boost"
WILD_CARD = "wild_card"
LUCKY_DICE = "lucky_dice"
COPYCAT = "copycat"
UNDO = "undo"
```
#### Создание предмета в магазине
```python
# Предмет добавляется через админку или миграцию
ShopItem(
item_type="consumable",
code="skip_exile",
name="Скип с изгнанием",
description="Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула.",
price=150, # Дороже обычного скипа (50)
rarity="rare",
)
```
#### Сервис: use_skip_exile
```python
# backend/app/services/consumables.py
async def use_skip_exile(
self,
db: AsyncSession,
user: User,
participant: Participant,
marathon: Marathon,
assignment: Assignment,
) -> dict:
"""
Skip assignment AND exile the game permanently.
- No streak loss
- No drop penalty
- Game is permanently excluded from participant's pool
"""
# Проверки как у обычного skip
if not marathon.allow_skips:
raise HTTPException(400, "Skips not allowed")
if marathon.max_skips_per_participant is not None:
if participant.skips_used >= marathon.max_skips_per_participant:
raise HTTPException(400, "Skip limit reached")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(400, "Can only skip active assignments")
# Получаем game_id
if assignment.is_playthrough:
game_id = assignment.game_id
else:
game_id = assignment.challenge.game_id
# Consume from inventory
item = await self._consume_item(db, user, ConsumableType.SKIP_EXILE.value)
# Mark assignment as dropped (без штрафа)
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Track skip usage
participant.skips_used += 1
# НОВОЕ: Добавляем игру в exiled
exiled = ExiledGame(
participant_id=participant.id,
game_id=game_id,
exiled_by="user",
)
db.add(exiled)
# Log usage
usage = ConsumableUsage(...)
db.add(usage)
return {
"success": True,
"skipped": True,
"exiled": True,
"game_id": game_id,
"penalty": 0,
"streak_preserved": True,
}
```
#### Изменение get_available_games_for_participant
```python
# backend/app/api/v1/games.py
async def get_available_games_for_participant(...):
# ... existing code ...
# НОВОЕ: Получаем изгнанные игры
exiled_result = await db.execute(
select(ExiledGame.game_id)
.where(ExiledGame.participant_id == participant.id)
)
exiled_game_ids = set(exiled_result.scalars().all())
# Фильтруем доступные игры
available_games = []
for game in games_with_content:
# НОВОЕ: Исключаем изгнанные игры
if game.id in exiled_game_ids:
continue
if game.game_type == GameType.PLAYTHROUGH.value:
if game.id not in finished_playthrough_game_ids:
available_games.append(game)
else:
# ...existing logic...
```
### 1.3 Frontend
#### Обновление UI использования консамблов
- В `PlayPage.tsx` добавить кнопку "Скип с изгнанием" рядом с обычным скипом
- Показывать предупреждение: "Игра будет навсегда исключена из вашего пула"
- В инвентаре показывать оба типа скипов отдельно
### 1.4 API Endpoints
```
POST /shop/use
Body: {
"item_code": "skip_exile",
"marathon_id": 123,
"assignment_id": 456
}
Response: {
"success": true,
"remaining_quantity": 2,
"effect_description": "Задание пропущено, игра изгнана",
"effect_data": {
"skipped": true,
"exiled": true,
"game_id": 789,
"penalty": 0,
"streak_preserved": true
}
}
```
---
## 2. Модерация марафона (скипы организаторами)
### 2.1 Концепция
Организаторы марафона могут скипать задания у участников:
- **Скип** — пропустить задание без штрафа (игра может выпасть снова)
- **Скип с изгнанием** — пропустить и исключить игру из пула участника
Причины использования:
- Участник просит пропустить игру (технические проблемы, неподходящая игра)
- Модерация спорных ситуаций
- Исправление ошибок
### 2.2 Backend
#### Новые эндпоинты
```python
# backend/app/api/v1/marathons.py
@router.post("/{marathon_id}/participants/{user_id}/skip-assignment")
async def organizer_skip_assignment(
marathon_id: int,
user_id: int,
data: OrganizerSkipRequest,
current_user: CurrentUser,
db: DbSession,
):
"""
Организатор скипает текущее задание участника.
Body:
exile: bool = False # Если true — скип с изгнанием
reason: str | None # Причина (опционально)
"""
await require_organizer(db, current_user, marathon_id)
# Получаем участника
participant = await get_participant_by_user_id(db, user_id, marathon_id)
if not participant:
raise HTTPException(404, "Participant not found")
# Получаем активное задание
assignment = await get_active_assignment(db, participant.id)
if not assignment:
raise HTTPException(400, "No active assignment")
# Определяем game_id
if assignment.is_playthrough:
game_id = assignment.game_id
else:
game_id = assignment.challenge.game_id
# Скипаем
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# НЕ увеличиваем skips_used (это модераторский скип, не консамбл)
# НЕ сбрасываем стрик
# НЕ увеличиваем drop_count
# Если exile — добавляем в exiled
if data.exile:
exiled = ExiledGame(
participant_id=participant.id,
game_id=game_id,
exiled_by="organizer",
reason=data.reason,
)
db.add(exiled)
# Логируем в Activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.MODERATION.value,
data={
"action": "skip_assignment",
"target_user_id": user_id,
"assignment_id": assignment.id,
"game_id": game_id,
"exile": data.exile,
"reason": data.reason,
}
)
db.add(activity)
await db.commit()
return {"success": True, "exiled": data.exile}
@router.get("/{marathon_id}/participants/{user_id}/exiled-games")
async def get_participant_exiled_games(
marathon_id: int,
user_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Список изгнанных игр участника (для организаторов)"""
await require_organizer(db, current_user, marathon_id)
# ...
@router.delete("/{marathon_id}/participants/{user_id}/exiled-games/{game_id}")
async def remove_exiled_game(
marathon_id: int,
user_id: int,
game_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Убрать игру из изгнанных (вернуть в пул)"""
await require_organizer(db, current_user, marathon_id)
# ...
```
#### Схемы
```python
# backend/app/schemas/marathon.py
class OrganizerSkipRequest(BaseModel):
exile: bool = False
reason: str | None = None
class ExiledGameResponse(BaseModel):
id: int
game_id: int
game_title: str
exiled_at: datetime
exiled_by: str
reason: str | None
```
### 2.3 Frontend
#### Страница участников марафона
В списке участников (`MarathonPage.tsx` или отдельная страница модерации):
```tsx
// Для каждого участника с активным заданием показываем кнопки:
<button onClick={() => skipAssignment(userId, false)}>
Скип
</button>
<button onClick={() => skipAssignment(userId, true)}>
Скип с изгнанием
</button>
```
#### Модальное окно скипа
```tsx
<Modal>
<h2>Скип задания у {participant.nickname}</h2>
<p>Текущее задание: {assignment.game.title}</p>
<label>
<input type="checkbox" checked={exile} onChange={...} />
Изгнать игру (не будет выпадать снова)
</label>
<textarea placeholder="Причина (опционально)" />
<button>Подтвердить</button>
</Modal>
```
### 2.4 Telegram уведомления
При модераторском скипе отправляем уведомление участнику:
```python
# backend/app/services/telegram_notifier.py
async def notify_assignment_skipped_by_moderator(
user: User,
marathon_title: str,
game_title: str,
exiled: bool,
reason: str | None,
moderator_nickname: str,
):
"""Уведомление о скипе задания организатором"""
if not user.telegram_id or not user.notify_moderation:
return
exile_text = "\n🚫 Игра исключена из вашего пула" if exiled else ""
reason_text = f"\n📝 Причина: {reason}" if reason else ""
message = f"""⏭️ <b>Задание пропущено</b>
Марафон: {marathon_title}
Игра: {game_title}
Организатор: {moderator_nickname}{exile_text}{reason_text}
Вы можете крутить колесо заново."""
await self._send_message(user.telegram_id, message)
```
#### Добавить поле notify_moderation в User
```python
# backend/app/models/user.py
class User(Base):
# ... existing fields ...
notify_moderation: bool = True # Уведомления о действиях модераторов
```
#### Интеграция в эндпоинт
```python
# В organizer_skip_assignment после db.commit():
await telegram_notifier.notify_assignment_skipped_by_moderator(
user=target_user,
marathon_title=marathon.title,
game_title=game.title,
exiled=data.exile,
reason=data.reason,
moderator_nickname=current_user.nickname,
)
```
---
## 3. Выдача предметов админами
### 3.1 Backend
#### Новые эндпоинты
```python
# backend/app/api/v1/shop.py
@router.post("/admin/users/{user_id}/items/grant", response_model=MessageResponse)
async def admin_grant_item(
user_id: int,
data: AdminGrantItemRequest,
current_user: CurrentUser,
db: DbSession,
):
"""
Выдать предмет пользователю (admin only).
Body:
item_id: int # ID предмета в магазине
quantity: int = 1 # Количество (для консамблов)
reason: str # Причина выдачи
"""
require_admin_with_2fa(current_user)
# Получаем пользователя
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(404, "User not found")
# Получаем предмет
item = await shop_service.get_item_by_id(db, data.item_id)
if not item:
raise HTTPException(404, "Item not found")
# Проверяем quantity для не-консамблов
if item.item_type != "consumable" and data.quantity > 1:
raise HTTPException(400, "Non-consumables can only have quantity 1")
# Проверяем, есть ли уже такой предмет
existing = await db.execute(
select(UserInventory)
.where(
UserInventory.user_id == user_id,
UserInventory.item_id == item.id,
)
)
inv_item = existing.scalar_one_or_none()
if inv_item:
if item.item_type == "consumable":
inv_item.quantity += data.quantity
else:
raise HTTPException(400, "User already owns this item")
else:
inv_item = UserInventory(
user_id=user_id,
item_id=item.id,
quantity=data.quantity if item.item_type == "consumable" else 1,
)
db.add(inv_item)
# Логируем
log = AdminLog(
admin_id=current_user.id,
action="ITEM_GRANT",
target_type="user",
target_id=user_id,
details={
"item_id": item.id,
"item_name": item.name,
"quantity": data.quantity,
"reason": data.reason,
}
)
db.add(log)
await db.commit()
return MessageResponse(
message=f"Granted {data.quantity}x {item.name} to {user.nickname}"
)
@router.get("/admin/users/{user_id}/inventory", response_model=list[InventoryItemResponse])
async def admin_get_user_inventory(
user_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Получить инвентарь пользователя (admin only)"""
require_admin_with_2fa(current_user)
# ...
@router.delete("/admin/users/{user_id}/inventory/{inventory_id}", response_model=MessageResponse)
async def admin_remove_item(
user_id: int,
inventory_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Удалить предмет из инвентаря пользователя (admin only)"""
require_admin_with_2fa(current_user)
# ...
```
#### Схемы
```python
class AdminGrantItemRequest(BaseModel):
item_id: int
quantity: int = 1
reason: str
```
### 3.2 Frontend
#### Новая страница: AdminItemsPage
`frontend/src/pages/admin/AdminItemsPage.tsx`
```tsx
export function AdminItemsPage() {
const [users, setUsers] = useState<User[]>([])
const [items, setItems] = useState<ShopItem[]>([])
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [grantModal, setGrantModal] = useState(false)
return (
<div>
<h1>Выдача предметов</h1>
{/* Поиск пользователя */}
<UserSearch onSelect={setSelectedUser} />
{selectedUser && (
<>
{/* Информация о пользователе */}
<UserCard user={selectedUser} />
{/* Инвентарь пользователя */}
<h2>Инвентарь</h2>
<UserInventoryList userId={selectedUser.id} />
{/* Кнопка выдачи */}
<button onClick={() => setGrantModal(true)}>
Выдать предмет
</button>
</>
)}
{/* Модалка выдачи */}
<GrantItemModal
isOpen={grantModal}
user={selectedUser}
items={items}
onClose={() => setGrantModal(false)}
onGrant={handleGrant}
/>
</div>
)
}
```
#### Компонент GrantItemModal
```tsx
function GrantItemModal({ isOpen, user, items, onClose, onGrant }) {
const [itemId, setItemId] = useState<number | null>(null)
const [quantity, setQuantity] = useState(1)
const [reason, setReason] = useState("")
const selectedItem = items.find(i => i.id === itemId)
const isConsumable = selectedItem?.item_type === "consumable"
return (
<Modal isOpen={isOpen} onClose={onClose}>
<h2>Выдать предмет: {user?.nickname}</h2>
{/* Выбор предмета */}
<select value={itemId} onChange={e => setItemId(Number(e.target.value))}>
<option value="">Выберите предмет</option>
{items.map(item => (
<option key={item.id} value={item.id}>
{item.name} ({item.item_type})
</option>
))}
</select>
{/* Количество (только для консамблов) */}
{isConsumable && (
<input
type="number"
min={1}
max={100}
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
/>
)}
{/* Причина */}
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Причина выдачи (обязательно)"
required
/>
<button
onClick={() => onGrant({ itemId, quantity, reason })}
disabled={!itemId || !reason}
>
Выдать
</button>
</Modal>
)
}
```
#### Добавление в роутер
```tsx
// frontend/src/App.tsx
import { AdminItemsPage } from '@/pages/admin/AdminItemsPage'
// В админских роутах:
<Route path="items" element={<AdminItemsPage />} />
```
#### Добавление в меню админки
```tsx
// frontend/src/pages/admin/AdminLayout.tsx
const adminLinks = [
{ path: '/admin', label: 'Дашборд' },
{ path: '/admin/users', label: 'Пользователи' },
{ path: '/admin/marathons', label: 'Марафоны' },
{ path: '/admin/items', label: 'Предметы' }, // NEW
{ path: '/admin/promo', label: 'Промокоды' },
// ...
]
```
---
## 4. Миграции
### 4.1 Создание таблицы exiled_games
```python
# backend/alembic/versions/XXX_add_exiled_games.py
def upgrade():
op.create_table(
'exiled_games',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('participant_id', sa.Integer(), sa.ForeignKey('participants.id', ondelete='CASCADE'), nullable=False),
sa.Column('game_id', sa.Integer(), sa.ForeignKey('games.id', ondelete='CASCADE'), nullable=False),
sa.Column('assignment_id', sa.Integer(), sa.ForeignKey('assignments.id', ondelete='SET NULL'), nullable=True),
sa.Column('exiled_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('exiled_by', sa.String(20), nullable=False), # user, organizer, admin
sa.Column('reason', sa.String(500), nullable=True),
# История восстановления
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('unexiled_at', sa.DateTime(), nullable=True),
sa.Column('unexiled_by', sa.String(20), nullable=True),
sa.UniqueConstraint('participant_id', 'game_id', name='unique_participant_game_exile'),
)
op.create_index('ix_exiled_games_participant_id', 'exiled_games', ['participant_id'])
op.create_index('ix_exiled_games_active', 'exiled_games', ['participant_id', 'is_active'])
def downgrade():
op.drop_table('exiled_games')
```
### 4.2 Добавление поля notify_moderation в users
```python
def upgrade():
op.add_column('users', sa.Column('notify_moderation', sa.Boolean(), server_default='true', nullable=False))
def downgrade():
op.drop_column('users', 'notify_moderation')
```
### 4.3 Добавление предмета skip_exile
```python
# Можно через миграцию или вручную через админку
# Если через миграцию:
def upgrade():
# ... create table ...
# Добавляем предмет в магазин
op.execute("""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, is_active, created_at)
VALUES (
'consumable',
'skip_exile',
'Скип с изгнанием',
'Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула.',
150,
'rare',
true,
NOW()
)
""")
```
---
## 5. Чеклист реализации
### Backend — Модели и миграции
- [ ] Создать модель ExiledGame (с полями assignment_id, is_active, unexiled_at, unexiled_by)
- [ ] Добавить поле notify_moderation в User
- [ ] Добавить ConsumableType.SKIP_EXILE
- [ ] Написать миграцию для exiled_games
- [ ] Написать миграцию для notify_moderation
- [ ] Добавить предмет skip_exile в магазин
### Backend — Скип с изгнанием
- [ ] Реализовать use_skip_exile в ConsumablesService
- [ ] Обновить get_available_games_for_participant (фильтр по is_active=True)
- [ ] Добавить обработку skip_exile в POST /shop/use
### Backend — Модерация
- [ ] Добавить эндпоинт POST /{marathon_id}/participants/{user_id}/skip-assignment
- [ ] Добавить эндпоинт GET /{marathon_id}/participants/{user_id}/exiled-games
- [ ] Добавить эндпоинт POST /{marathon_id}/participants/{user_id}/exiled-games/{game_id}/restore
- [ ] Добавить notify_assignment_skipped_by_moderator в telegram_notifier
### Backend — Админка предметов
- [ ] Добавить эндпоинт POST /shop/admin/users/{user_id}/items/grant
- [ ] Добавить эндпоинт GET /shop/admin/users/{user_id}/inventory
- [ ] Добавить эндпоинт DELETE /shop/admin/users/{user_id}/inventory/{inventory_id}
### Frontend — Игрок
- [ ] Добавить кнопку "Скип с изгнанием" в PlayPage
- [ ] Добавить чекбокс notify_moderation в настройках профиля
### Frontend — Админка
- [ ] Создать AdminItemsPage
- [ ] Добавить GrantItemModal
- [ ] Добавить роут /admin/items
- [ ] Добавить пункт меню в AdminLayout
### Frontend — Модерация марафона
- [ ] Создать UI модерации для организаторов (скип заданий)
- [ ] Добавить список изгнанных игр участника
- [ ] Добавить кнопку восстановления игры в пул
### Тестирование
- [ ] Тест: use_skip_exile корректно исключает игру
- [ ] Тест: изгнанная игра не выпадает при спине
- [ ] Тест: восстановленная игра (is_active=False) снова выпадает
- [ ] Тест: организатор может скипать задания
- [ ] Тест: Telegram уведомление отправляется при модераторском скипе
- [ ] Тест: админ может выдавать предметы
- [ ] Тест: лимиты скипов работают корректно
---
## 6. Вопросы для обсуждения
1. **Лимиты изгнания**: Нужен ли лимит на количество изгнанных игр у участника?
2. **Отмена изгнания**: Может ли участник сам отменить изгнание? Или только организатор?
3. **Стоимость**: Текущая цена skip_exile = 150 монет (обычный skip = 50). Подходит?
4. **Телеграм уведомления**: Нужны ли уведомления участнику при модераторском скипе?

View File

@@ -25,6 +25,14 @@ import { StaticContentPage } from '@/pages/StaticContentPage'
import { NotFoundPage } from '@/pages/NotFoundPage' import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage' import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage' import { ServerErrorPage } from '@/pages/ServerErrorPage'
import { ShopPage } from '@/pages/ShopPage'
import { InventoryPage } from '@/pages/InventoryPage'
// Widget Pages (for OBS)
import LeaderboardWidget from '@/pages/widget/LeaderboardWidget'
import CurrentWidget from '@/pages/widget/CurrentWidget'
import ProgressWidget from '@/pages/widget/ProgressWidget'
import CombinedWidget from '@/pages/widget/CombinedWidget'
// Admin Pages // Admin Pages
import { import {
@@ -35,6 +43,8 @@ import {
AdminLogsPage, AdminLogsPage,
AdminBroadcastPage, AdminBroadcastPage,
AdminContentPage, AdminContentPage,
AdminPromoCodesPage,
AdminGrantItemPage,
} from '@/pages/admin' } from '@/pages/admin'
// Protected route wrapper // Protected route wrapper
@@ -83,6 +93,12 @@ function App() {
<ToastContainer /> <ToastContainer />
<ConfirmModal /> <ConfirmModal />
<Routes> <Routes>
{/* Widget routes (no layout, for OBS browser source) */}
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
<Route path="/widget/current" element={<CurrentWidget />} />
<Route path="/widget/progress" element={<ProgressWidget />} />
<Route path="/widget/combined" element={<CombinedWidget />} />
<Route path="/" element={<Layout />}> <Route path="/" element={<Layout />}>
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
@@ -187,6 +203,25 @@ function App() {
<Route path="users/:id" element={<UserProfilePage />} /> <Route path="users/:id" element={<UserProfilePage />} />
{/* Shop routes */}
<Route
path="shop"
element={
<ProtectedRoute>
<ShopPage />
</ProtectedRoute>
}
/>
<Route
path="inventory"
element={
<ProtectedRoute>
<InventoryPage />
</ProtectedRoute>
}
/>
{/* Easter egg - 418 I'm a teapot */} {/* Easter egg - 418 I'm a teapot */}
<Route path="418" element={<TeapotPage />} /> <Route path="418" element={<TeapotPage />} />
<Route path="teapot" element={<TeapotPage />} /> <Route path="teapot" element={<TeapotPage />} />
@@ -207,7 +242,9 @@ function App() {
> >
<Route index element={<AdminDashboardPage />} /> <Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} /> <Route path="users" element={<AdminUsersPage />} />
<Route path="users/:userId/grant-item" element={<AdminGrantItemPage />} />
<Route path="marathons" element={<AdminMarathonsPage />} /> <Route path="marathons" element={<AdminMarathonsPage />} />
<Route path="promo" element={<AdminPromoCodesPage />} />
<Route path="logs" element={<AdminLogsPage />} /> <Route path="logs" element={<AdminLogsPage />} />
<Route path="broadcast" element={<AdminBroadcastPage />} /> <Route path="broadcast" element={<AdminBroadcastPage />} />
<Route path="content" element={<AdminContentPage />} /> <Route path="content" element={<AdminContentPage />} />

View File

@@ -76,6 +76,14 @@ export const adminApi = {
await client.post(`/admin/marathons/${id}/force-finish`) await client.post(`/admin/marathons/${id}/force-finish`)
}, },
certifyMarathon: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/certify`)
},
revokeCertification: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/revoke-certification`)
},
// Stats // Stats
getStats: async (): Promise<PlatformStats> => { getStats: async (): Promise<PlatformStats> => {
const response = await client.get<PlatformStats>('/admin/stats') const response = await client.get<PlatformStats>('/admin/stats')

Some files were not shown because too many files have changed in this diff Show More