Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
381
docs/disputes.md
Normal file
381
docs/disputes.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Система оспаривания (Disputes)
|
||||
|
||||
Система оспаривания позволяет участникам марафона проверять доказательства (пруфы) выполненных заданий друг друга и голосовать за их валидность.
|
||||
|
||||
## Общий принцип работы
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ ЖИЗНЕННЫЙ ЦИКЛ ДИСПУТА │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Участник A Участник B Все участники
|
||||
выполняет задание замечает проблему голосуют
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ 24 часа ┌───────────┐ 24 часа ┌───────────┐
|
||||
│ Завершено │ ─────────────────▶ │ Оспорено │ ─────────────▶ │ Решено │
|
||||
│ │ окно оспаривания │ (OPEN) │ голосование │ │
|
||||
└───────────┘ └───────────┘ └───────────┘
|
||||
│ │ │
|
||||
│ │ ├──▶ VALID (пруф OK)
|
||||
│ │ │ Задание остаётся
|
||||
│ │ │
|
||||
│ │ └──▶ INVALID (пруф не OK)
|
||||
│ │ Задание возвращается
|
||||
│ │
|
||||
└──────────────────────────────────┘
|
||||
Если не оспорено — задание засчитано
|
||||
```
|
||||
|
||||
## Кто может оспаривать
|
||||
|
||||
| Условие | Можно оспорить? |
|
||||
|---------|-----------------|
|
||||
| Своё задание | ❌ Нельзя |
|
||||
| Чужое задание (статус COMPLETED) | ✅ Можно (в течение 24 часов) |
|
||||
| Чужое задание (статус ACTIVE/DROPPED) | ❌ Нельзя |
|
||||
| Прошло более 24 часов с момента выполнения | ❌ Нельзя |
|
||||
| Уже есть активный диспут на это задание | ❌ Нельзя |
|
||||
|
||||
## Типы оспариваемых заданий
|
||||
|
||||
### 1. Обычные челленджи
|
||||
|
||||
Можно оспорить выполнение любого челленджа. При признании пруфа невалидным:
|
||||
- Задание переходит в статус `RETURNED`
|
||||
- Очки снимаются с участника
|
||||
- Участник должен переделать задание
|
||||
|
||||
### 2. Прохождения игр (Playthrough)
|
||||
|
||||
Основное задание прохождения можно оспорить. При признании невалидным:
|
||||
- Основное задание переходит в статус `RETURNED`
|
||||
- Очки снимаются
|
||||
- **Все бонусные челленджи сбрасываются** в статус `PENDING`
|
||||
|
||||
### 3. Бонусные челленджи
|
||||
|
||||
Каждый бонусный челлендж можно оспорить **отдельно**. При признании невалидным:
|
||||
- Только этот бонусный челлендж сбрасывается в `PENDING`
|
||||
- Участник может переделать его
|
||||
- Основное задание и другие бонусы не затрагиваются
|
||||
|
||||
**Важно:** Очки за бонусные челленджи начисляются только при завершении основного задания. Поэтому при оспаривании бонуса очки не снимаются — просто сбрасывается статус.
|
||||
|
||||
## Процесс голосования
|
||||
|
||||
### Создание диспута
|
||||
|
||||
1. Участник нажимает "Оспорить" на странице деталей задания
|
||||
2. Вводит причину оспаривания (минимум 10 символов)
|
||||
3. Создаётся диспут со статусом `OPEN`
|
||||
4. Владельцу задания отправляется уведомление в Telegram
|
||||
|
||||
### Голосование
|
||||
|
||||
- **Любой участник марафона** может голосовать
|
||||
- Два варианта: "Валидно" (пруф OK) или "Невалидно" (пруф не OK)
|
||||
- Можно **изменить** свой голос до завершения голосования
|
||||
- Голосование длится **24 часа** с момента создания диспута
|
||||
|
||||
### Комментарии
|
||||
|
||||
- Участники могут оставлять комментарии для обсуждения
|
||||
- Комментарии помогают другим участникам принять решение
|
||||
- Комментарии доступны только пока диспут открыт
|
||||
|
||||
## Разрешение диспута
|
||||
|
||||
### Автоматическое (по таймеру)
|
||||
|
||||
Через 24 часа диспут автоматически разрешается:
|
||||
- Система подсчитывает голоса
|
||||
- При равенстве голосов — **в пользу обвиняемого** (пруф валиден)
|
||||
- Результат: `RESOLVED_VALID` или `RESOLVED_INVALID`
|
||||
|
||||
**Технически:** Фоновый планировщик (`DisputeScheduler`) проверяет истёкшие диспуты каждые 5 минут.
|
||||
|
||||
### Результаты
|
||||
|
||||
| Результат | Условие | Последствия |
|
||||
|-----------|---------|-------------|
|
||||
| `RESOLVED_VALID` | Голосов "валидно" ≥ голосов "невалидно" | Задание остаётся выполненным |
|
||||
| `RESOLVED_INVALID` | Голосов "невалидно" > голосов "валидно" | Задание возвращается |
|
||||
|
||||
### Что происходит при INVALID
|
||||
|
||||
**Для обычного задания:**
|
||||
1. Статус → `RETURNED`
|
||||
2. Очки (`points_earned`) вычитаются из общего счёта участника
|
||||
3. Пруфы сохраняются для истории
|
||||
|
||||
**Для прохождения:**
|
||||
1. Основное задание → `RETURNED`
|
||||
2. Очки вычитаются
|
||||
3. Все бонусные челленджи сбрасываются:
|
||||
- Статус → `PENDING`
|
||||
- Пруфы удаляются
|
||||
- Очки обнуляются
|
||||
|
||||
**Для бонусного челленджа:**
|
||||
1. Только этот бонус → `PENDING`
|
||||
2. Пруфы удаляются
|
||||
3. Можно переделать
|
||||
|
||||
## API эндпоинты
|
||||
|
||||
### Создание диспута
|
||||
|
||||
```
|
||||
POST /api/v1/assignments/{assignment_id}/dispute
|
||||
POST /api/v1/bonus-assignments/{bonus_id}/dispute
|
||||
|
||||
Body: { "reason": "Описание проблемы с пруфом..." }
|
||||
```
|
||||
|
||||
### Голосование
|
||||
|
||||
```
|
||||
POST /api/v1/disputes/{dispute_id}/vote
|
||||
|
||||
Body: { "vote": true } // true = валидно, false = невалидно
|
||||
```
|
||||
|
||||
### Комментарии
|
||||
|
||||
```
|
||||
POST /api/v1/disputes/{dispute_id}/comments
|
||||
|
||||
Body: { "text": "Текст комментария" }
|
||||
```
|
||||
|
||||
### Получение информации
|
||||
|
||||
```
|
||||
GET /api/v1/assignments/{assignment_id}
|
||||
|
||||
// В ответе включено поле dispute с полной информацией:
|
||||
{
|
||||
"dispute": {
|
||||
"id": 1,
|
||||
"status": "open",
|
||||
"reason": "...",
|
||||
"votes_valid": 3,
|
||||
"votes_invalid": 2,
|
||||
"my_vote": true,
|
||||
"expires_at": "2024-12-30T12:00:00Z",
|
||||
"comments": [...],
|
||||
"votes": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Структура базы данных
|
||||
|
||||
### Таблица `disputes`
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `id` | INT | PK |
|
||||
| `assignment_id` | INT | FK → assignments (nullable для бонусов) |
|
||||
| `bonus_assignment_id` | INT | FK → bonus_assignments (nullable для основных) |
|
||||
| `raised_by_id` | INT | FK → users |
|
||||
| `reason` | TEXT | Причина оспаривания |
|
||||
| `status` | VARCHAR(20) | open / valid / invalid |
|
||||
| `created_at` | DATETIME | Время создания |
|
||||
| `resolved_at` | DATETIME | Время разрешения |
|
||||
|
||||
**Ограничение:** Либо `assignment_id`, либо `bonus_assignment_id` должен быть заполнен (не оба).
|
||||
|
||||
### Таблица `dispute_votes`
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `id` | INT | PK |
|
||||
| `dispute_id` | INT | FK → disputes |
|
||||
| `user_id` | INT | FK → users |
|
||||
| `vote` | BOOLEAN | true = валидно, false = невалидно |
|
||||
| `created_at` | DATETIME | Время голоса |
|
||||
|
||||
**Ограничение:** Один голос на участника (`UNIQUE dispute_id + user_id`).
|
||||
|
||||
### Таблица `dispute_comments`
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `id` | INT | PK |
|
||||
| `dispute_id` | INT | FK → disputes |
|
||||
| `user_id` | INT | FK → users |
|
||||
| `text` | TEXT | Текст комментария |
|
||||
| `created_at` | DATETIME | Время комментария |
|
||||
|
||||
## UI компоненты
|
||||
|
||||
### Кнопка "Оспорить"
|
||||
|
||||
Появляется на странице деталей задания (`/assignments/{id}`) если:
|
||||
- Статус задания: `COMPLETED`
|
||||
- Это не своё задание
|
||||
- Прошло меньше 24 часов с момента выполнения
|
||||
- Нет активного диспута
|
||||
|
||||
### Секция диспута
|
||||
|
||||
Показывается если есть активный или завершённый диспут:
|
||||
- Статус (открыт / валиден / невалиден)
|
||||
- Таймер до окончания (для открытых)
|
||||
- Причина оспаривания
|
||||
- Кнопки голосования с счётчиками
|
||||
- Секция комментариев
|
||||
|
||||
### Для бонусных челленджей
|
||||
|
||||
На каждом бонусном челлендже:
|
||||
- Маленькая кнопка "Оспорить" (если можно)
|
||||
- Бейдж статуса диспута
|
||||
- Компактное голосование прямо в карточке бонуса
|
||||
|
||||
## Уведомления
|
||||
|
||||
### Telegram уведомления
|
||||
|
||||
| Событие | Получатель | Сообщение |
|
||||
|---------|------------|-----------|
|
||||
| Создание диспута | Владелец задания | "Ваше задание X оспорено в марафоне Y" |
|
||||
| Результат: валидно | Владелец задания | "Диспут по заданию X решён в вашу пользу" |
|
||||
| Результат: невалидно | Владелец задания | "Диспут по заданию X решён не в вашу пользу, задание возвращено" |
|
||||
|
||||
## Конфигурация
|
||||
|
||||
```python
|
||||
# backend/app/api/v1/assignments.py
|
||||
DISPUTE_WINDOW_HOURS = 24 # Окно для создания диспута
|
||||
|
||||
# backend/app/services/dispute_scheduler.py
|
||||
CHECK_INTERVAL_SECONDS = 300 # Проверка каждые 5 минут
|
||||
DISPUTE_WINDOW_HOURS = 24 # Время голосования
|
||||
```
|
||||
|
||||
## Пример сценария
|
||||
|
||||
### Сценарий 1: Успешное оспаривание
|
||||
|
||||
1. **Иван** выполняет челлендж "Пройти уровень без смертей"
|
||||
2. **Иван** прикладывает скриншот финального экрана
|
||||
3. **Петр** открывает детали задания и видит, что на скриншоте есть смерти
|
||||
4. **Петр** нажимает "Оспорить" и пишет: "На скриншоте видно 3 смерти"
|
||||
5. Участники марафона голосуют: 5 за "невалидно", 2 за "валидно"
|
||||
6. Через 24 часа диспут закрывается как `RESOLVED_INVALID`
|
||||
7. Задание Ивана возвращается, очки снимаются
|
||||
8. Иван получает уведомление и должен переделать задание
|
||||
|
||||
### Сценарий 2: Оспаривание бонуса
|
||||
|
||||
1. **Анна** проходит игру и выполняет бонусный челлендж
|
||||
2. **Сергей** замечает проблему с пруфом бонуса
|
||||
3. **Сергей** оспаривает только бонусный челлендж
|
||||
4. Голосование: 4 за "невалидно", 1 за "валидно"
|
||||
5. Результат: бонус сбрасывается в `PENDING`
|
||||
6. Основное задание Анны **не затронуто**
|
||||
7. Анна может переделать бонус (пока основное задание активно)
|
||||
|
||||
## Ручное разрешение диспутов
|
||||
|
||||
Администраторы системы и организаторы марафонов могут вручную разрешать диспуты, не дожидаясь окончания 24-часового окна голосования.
|
||||
|
||||
### Кто может разрешать
|
||||
|
||||
| Роль | Доступ |
|
||||
|------|--------|
|
||||
| **Системный админ** | Все диспуты во всех марафонах (`/admin/disputes`) |
|
||||
| **Организатор марафона** | Только диспуты в своём марафоне (секция "Оспаривания" на странице марафона) |
|
||||
|
||||
### Интерфейс для системных админов
|
||||
|
||||
**Путь:** `/admin/disputes`
|
||||
|
||||
- Отдельная страница в админ-панели
|
||||
- Фильтры: "Открытые" / "Все"
|
||||
- Показывает диспуты из всех марафонов
|
||||
- Информация: марафон, задание, участник, кто оспорил, причина
|
||||
- Счётчик голосов и время до истечения
|
||||
- Кнопки "Валидно" / "Невалидно" для мгновенного решения
|
||||
|
||||
### Интерфейс для организаторов
|
||||
|
||||
**Путь:** На странице марафона (`/marathons/{id}`) → секция "Оспаривания"
|
||||
|
||||
- Доступна только организаторам активного марафона
|
||||
- Показывает только диспуты текущего марафона
|
||||
- Компактный вид с возможностью раскрытия
|
||||
- Ссылка на страницу задания для детального просмотра
|
||||
|
||||
### API для ручного разрешения
|
||||
|
||||
**Системные админы:**
|
||||
```
|
||||
GET /api/v1/admin/disputes?status_filter=open|all
|
||||
POST /api/v1/admin/disputes/{dispute_id}/resolve
|
||||
|
||||
Body: { "is_valid": true|false }
|
||||
```
|
||||
|
||||
**Организаторы марафона:**
|
||||
```
|
||||
GET /api/v1/marathons/{marathon_id}/disputes?status_filter=open|all
|
||||
POST /api/v1/marathons/{marathon_id}/disputes/{dispute_id}/resolve
|
||||
|
||||
Body: { "is_valid": true|false }
|
||||
```
|
||||
|
||||
### Что происходит при ручном разрешении
|
||||
|
||||
Логика идентична автоматическому разрешению:
|
||||
|
||||
**При `is_valid: true`:**
|
||||
- Диспут закрывается как `RESOLVED_VALID`
|
||||
- Задание остаётся выполненным
|
||||
- Участник получает уведомление
|
||||
|
||||
**При `is_valid: false`:**
|
||||
- Диспут закрывается как `RESOLVED_INVALID`
|
||||
- Задание возвращается, очки снимаются
|
||||
- Участник получает уведомление
|
||||
|
||||
### Важно: логика снятия очков за бонусы
|
||||
|
||||
При отклонении бонусного диспута система проверяет статус основного прохождения:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ БОНУС ПРИЗНАН НЕВАЛИДНЫМ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Основное прохождение Основное прохождение │
|
||||
│ НЕ завершено? УЖЕ завершено? │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ Просто │ │ Вычитаем │ │
|
||||
│ │ сбросить │ │ очки из │ │
|
||||
│ │ бонус │ │ участника │ │
|
||||
│ └───────────┘ └───────────┘ │
|
||||
│ (очки ещё не (очки уже были │
|
||||
│ были начислены) начислены при │
|
||||
│ завершении прохождения) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Почему так?** Очки за бонусные челленджи начисляются только в момент завершения основного прохождения (чтобы нельзя было получить очки за бонусы и потом дропнуть основное задание).
|
||||
|
||||
## Логирование действий
|
||||
|
||||
Ручное разрешение диспутов логируется в системе:
|
||||
|
||||
| Действие | Тип лога |
|
||||
|----------|----------|
|
||||
| Админ подтвердил пруф | `DISPUTE_RESOLVE_VALID` |
|
||||
| Админ отклонил пруф | `DISPUTE_RESOLVE_INVALID` |
|
||||
|
||||
Логи доступны в `/admin/logs` для аудита действий администраторов.
|
||||
906
docs/tz-game-types.md
Normal file
906
docs/tz-game-types.md
Normal file
@@ -0,0 +1,906 @@
|
||||
# ТЗ: Типы игр "Прохождение" и "Челленджи"
|
||||
|
||||
## Описание задачи
|
||||
|
||||
Добавить систему типов для игр, которая определяет логику выпадения заданий при спине колеса.
|
||||
|
||||
### Два типа игр:
|
||||
|
||||
| Тип | Название | Поведение при выпадении |
|
||||
|-----|----------|------------------------|
|
||||
| `playthrough` | Прохождение | Основное задание — пройти игру. Челленджи становятся **дополнительными** заданиями |
|
||||
| `challenges` | Челленджи | Выдаётся **случайный челлендж** из списка челленджей игры (текущее поведение) |
|
||||
|
||||
---
|
||||
|
||||
## Детальное описание логики
|
||||
|
||||
### Тип "Прохождение" (`playthrough`)
|
||||
|
||||
**При создании игры** с типом "Прохождение" указываются дополнительные поля:
|
||||
- **Очки за прохождение** (`playthrough_points`) — количество очков за прохождение игры
|
||||
- **Описание прохождения** (`playthrough_description`) — описание задания (например: "Пройти основной сюжет игры")
|
||||
- **Тип пруфа** (`playthrough_proof_type`) — screenshot / video / steam
|
||||
- **Подсказка для пруфа** (`playthrough_proof_hint`) — опционально (например: "Скриншот финальных титров")
|
||||
|
||||
**При выпадении игры** с типом "Прохождение":
|
||||
|
||||
1. **Основное задание**: Пройти игру (очки и описание берутся из полей игры)
|
||||
2. **Дополнительные задания**: Все челленджи игры становятся **опциональными** бонусными заданиями
|
||||
3. **Пруфы**:
|
||||
- Требуется **отдельный пруф на прохождение** игры (тип из `playthrough_proof_type`)
|
||||
- Для каждого бонусного челленджа **тоже требуется пруф** (по типу челленджа)
|
||||
- **Прикрепление файла не обязательно** — можно отправить только комментарий со ссылкой на видео
|
||||
4. **Система очков**:
|
||||
- За основное прохождение — `playthrough_points` (указанные при создании)
|
||||
- За каждый выполненный доп. челлендж — очки челленджа
|
||||
5. **Завершение**: Задание считается выполненным после прохождения основной игры. Доп. челленджи **не обязательны** — можно выполнять параллельно или игнорировать
|
||||
|
||||
### Тип "Челленджи" (`challenges`)
|
||||
|
||||
При выпадении игры с типом "Челленджи":
|
||||
|
||||
1. Выбирается **один случайный челлендж** из списка челленджей игры
|
||||
2. Участник выполняет только этот челлендж
|
||||
3. Логика остаётся **без изменений** (текущее поведение системы)
|
||||
|
||||
---
|
||||
|
||||
### Фильтрация игр при спине
|
||||
|
||||
При выборе игры для спина необходимо исключать уже пройденные/дропнутые игры:
|
||||
|
||||
| Тип игры | Условие исключения из спина |
|
||||
|----------|----------------------------|
|
||||
| `playthrough` | Игра **исключается**, если участник **завершил ИЛИ дропнул** прохождение этой игры |
|
||||
| `challenges` | Игра **исключается**, только если участник выполнил **все** челленджи этой игры |
|
||||
|
||||
**Логика:**
|
||||
```
|
||||
Для каждой игры в марафоне:
|
||||
ЕСЛИ game_type == "playthrough":
|
||||
Проверить: есть ли Assignment с is_playthrough=True для этой игры
|
||||
со статусом COMPLETED или DROPPED?
|
||||
Если да → исключить игру
|
||||
|
||||
ЕСЛИ game_type == "challenges":
|
||||
Получить все челленджи игры
|
||||
Получить все завершённые Assignment участника для этих челленджей
|
||||
Если количество завершённых == количество челленджей → исключить игру
|
||||
```
|
||||
|
||||
**Важно:** Если все игры исключены (всё пройдено), спин должен вернуть ошибку или специальный статус "Все игры пройдены!"
|
||||
|
||||
### Бонусные челленджи
|
||||
|
||||
Бонусные челленджи доступны **только пока основное задание активно**:
|
||||
- После **завершения** прохождения — бонусные челленджи недоступны
|
||||
- После **дропа** прохождения — бонусные челленджи недоступны
|
||||
- Нельзя вернуться к бонусным челленджам позже
|
||||
|
||||
### Взаимодействие с событиями
|
||||
|
||||
**Все события игнорируются** при выпадении игры с типом `playthrough`:
|
||||
|
||||
| Событие | Поведение для `playthrough` |
|
||||
|---------|----------------------------|
|
||||
| **JACKPOT** (x3 за hard) | Игнорируется |
|
||||
| **GAME_CHOICE** (выбор из 3) | Игнорируется |
|
||||
| **GOLDEN_HOUR** (x1.5) | Игнорируется |
|
||||
| **DOUBLE_RISK** (x0.5, бесплатный дроп) | Игнорируется |
|
||||
| **COMMON_ENEMY** | Игнорируется |
|
||||
| **SWAP** | Игнорируется |
|
||||
|
||||
Игрок получает стандартные очки `playthrough_points` без модификаторов.
|
||||
|
||||
---
|
||||
|
||||
## Изменения в Backend
|
||||
|
||||
### 1. Модель Game (`backend/app/models/game.py`)
|
||||
|
||||
Добавить поля для типа игры и прохождения:
|
||||
|
||||
```python
|
||||
class GameType(str, Enum):
|
||||
PLAYTHROUGH = "playthrough" # Прохождение
|
||||
CHALLENGES = "challenges" # Челленджи
|
||||
|
||||
class Game(Base):
|
||||
# ... существующие поля ...
|
||||
|
||||
# Тип игры
|
||||
game_type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default=GameType.CHALLENGES.value,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Поля для типа "Прохождение" (nullable, заполняются только для playthrough)
|
||||
playthrough_points: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
nullable=True
|
||||
)
|
||||
playthrough_description: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
nullable=True
|
||||
)
|
||||
playthrough_proof_type: Mapped[str | None] = mapped_column(
|
||||
String(20), # screenshot, video, steam
|
||||
nullable=True
|
||||
)
|
||||
playthrough_proof_hint: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
nullable=True
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Схемы Pydantic (`backend/app/schemas/`)
|
||||
|
||||
Обновить схемы для Game:
|
||||
|
||||
```python
|
||||
# schemas/game.py
|
||||
class GameType(str, Enum):
|
||||
PLAYTHROUGH = "playthrough"
|
||||
CHALLENGES = "challenges"
|
||||
|
||||
class GameCreate(BaseModel):
|
||||
# ... существующие поля ...
|
||||
game_type: GameType = GameType.CHALLENGES
|
||||
|
||||
# Поля для типа "Прохождение"
|
||||
playthrough_points: int | None = None
|
||||
playthrough_description: str | None = None
|
||||
playthrough_proof_type: ProofType | None = None
|
||||
playthrough_proof_hint: str | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_playthrough_fields(self) -> Self:
|
||||
if self.game_type == GameType.PLAYTHROUGH:
|
||||
if self.playthrough_points is None:
|
||||
raise ValueError('playthrough_points обязателен для типа "Прохождение"')
|
||||
if self.playthrough_description is None:
|
||||
raise ValueError('playthrough_description обязателен для типа "Прохождение"')
|
||||
if self.playthrough_proof_type is None:
|
||||
raise ValueError('playthrough_proof_type обязателен для типа "Прохождение"')
|
||||
if self.playthrough_points < 1 or self.playthrough_points > 500:
|
||||
raise ValueError('playthrough_points должен быть от 1 до 500')
|
||||
return self
|
||||
|
||||
class GameResponse(BaseModel):
|
||||
# ... существующие поля ...
|
||||
game_type: GameType
|
||||
playthrough_points: int | None
|
||||
playthrough_description: str | None
|
||||
playthrough_proof_type: ProofType | None
|
||||
playthrough_proof_hint: str | None
|
||||
|
||||
class GameUpdate(BaseModel):
|
||||
"""Схема для редактирования игры"""
|
||||
title: str | None = None
|
||||
download_url: str | None = None
|
||||
genre: str | None = None
|
||||
game_type: GameType | None = None
|
||||
playthrough_points: int | None = None
|
||||
playthrough_description: str | None = None
|
||||
playthrough_proof_type: ProofType | None = None
|
||||
playthrough_proof_hint: str | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_playthrough_fields(self) -> Self:
|
||||
# Валидация только если меняем на playthrough
|
||||
if self.game_type == GameType.PLAYTHROUGH:
|
||||
if self.playthrough_points is not None:
|
||||
if self.playthrough_points < 1 or self.playthrough_points > 500:
|
||||
raise ValueError('playthrough_points должен быть от 1 до 500')
|
||||
return self
|
||||
```
|
||||
|
||||
### 3. Миграция Alembic
|
||||
|
||||
```python
|
||||
# Новая миграция
|
||||
def upgrade():
|
||||
# Тип игры
|
||||
op.add_column('games', sa.Column(
|
||||
'game_type',
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default='challenges'
|
||||
))
|
||||
|
||||
# Поля для прохождения
|
||||
op.add_column('games', sa.Column(
|
||||
'playthrough_points',
|
||||
sa.Integer(),
|
||||
nullable=True
|
||||
))
|
||||
op.add_column('games', sa.Column(
|
||||
'playthrough_description',
|
||||
sa.Text(),
|
||||
nullable=True
|
||||
))
|
||||
op.add_column('games', sa.Column(
|
||||
'playthrough_proof_type',
|
||||
sa.String(20),
|
||||
nullable=True
|
||||
))
|
||||
op.add_column('games', sa.Column(
|
||||
'playthrough_proof_hint',
|
||||
sa.Text(),
|
||||
nullable=True
|
||||
))
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('games', 'playthrough_proof_hint')
|
||||
op.drop_column('games', 'playthrough_proof_type')
|
||||
op.drop_column('games', 'playthrough_description')
|
||||
op.drop_column('games', 'playthrough_points')
|
||||
op.drop_column('games', 'game_type')
|
||||
```
|
||||
|
||||
### 4. Логика спина (`backend/app/api/v1/wheel.py`)
|
||||
|
||||
Изменить функцию `spin_wheel`:
|
||||
|
||||
```python
|
||||
async def get_available_games(
|
||||
participant: Participant,
|
||||
marathon_games: list[Game],
|
||||
db: AsyncSession
|
||||
) -> list[Game]:
|
||||
"""Получить список игр, доступных для спина"""
|
||||
available = []
|
||||
|
||||
for game in marathon_games:
|
||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||
# Проверяем, прошёл ли участник эту игру
|
||||
# Исключаем если COMPLETED или DROPPED
|
||||
finished = await db.scalar(
|
||||
select(Assignment)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.game_id == game.id,
|
||||
Assignment.is_playthrough == True,
|
||||
Assignment.status.in_([
|
||||
AssignmentStatus.COMPLETED.value,
|
||||
AssignmentStatus.DROPPED.value
|
||||
])
|
||||
)
|
||||
)
|
||||
if not finished:
|
||||
available.append(game)
|
||||
|
||||
else: # GameType.CHALLENGES
|
||||
# Проверяем, остались ли невыполненные челленджи
|
||||
completed_challenge_ids = await db.scalars(
|
||||
select(Assignment.challenge_id)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.challenge_id.in_([c.id for c in game.challenges]),
|
||||
Assignment.status == AssignmentStatus.COMPLETED.value
|
||||
)
|
||||
)
|
||||
completed_ids = set(completed_challenge_ids.all())
|
||||
all_challenge_ids = {c.id for c in game.challenges}
|
||||
|
||||
if completed_ids != all_challenge_ids:
|
||||
available.append(game)
|
||||
|
||||
return available
|
||||
|
||||
|
||||
async def spin_wheel(...):
|
||||
# Получаем доступные игры (исключаем пройденные)
|
||||
available_games = await get_available_games(participant, marathon_games, db)
|
||||
|
||||
if not available_games:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Все игры пройдены! Поздравляем!"
|
||||
)
|
||||
|
||||
game = random.choice(available_games)
|
||||
|
||||
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||
# Для playthrough НЕ выбираем челлендж — основное задание это прохождение
|
||||
# Данные берутся из полей игры: playthrough_points, playthrough_description
|
||||
challenge = None # Или создаём виртуальный объект
|
||||
|
||||
# Все челленджи игры становятся дополнительными
|
||||
bonus_challenges = list(game.challenges)
|
||||
|
||||
# Создаём Assignment с флагом is_playthrough=True
|
||||
assignment = Assignment(
|
||||
participant_id=participant.id,
|
||||
challenge_id=None, # Нет привязки к челленджу
|
||||
game_id=game.id, # Новое поле — привязка к игре
|
||||
is_playthrough=True,
|
||||
status=AssignmentStatus.ACTIVE,
|
||||
# ...
|
||||
)
|
||||
|
||||
else: # GameType.CHALLENGES
|
||||
# Выбираем случайный НЕВЫПОЛНЕННЫЙ челлендж
|
||||
completed_challenge_ids = await db.scalars(
|
||||
select(Assignment.challenge_id)
|
||||
.where(
|
||||
Assignment.participant_id == participant.id,
|
||||
Assignment.challenge_id.in_([c.id for c in game.challenges]),
|
||||
Assignment.status == AssignmentStatus.COMPLETED.value
|
||||
)
|
||||
)
|
||||
completed_ids = set(completed_challenge_ids.all())
|
||||
|
||||
available_challenges = [c for c in game.challenges if c.id not in completed_ids]
|
||||
challenge = random.choice(available_challenges)
|
||||
bonus_challenges = []
|
||||
|
||||
assignment = Assignment(
|
||||
participant_id=participant.id,
|
||||
challenge_id=challenge.id,
|
||||
is_playthrough=False,
|
||||
status=AssignmentStatus.ACTIVE,
|
||||
# ...
|
||||
)
|
||||
|
||||
# ... сохранение Assignment ...
|
||||
```
|
||||
|
||||
### 5. Модель Assignment (`backend/app/models/assignment.py`)
|
||||
|
||||
Обновить модель для поддержки прохождений:
|
||||
|
||||
```python
|
||||
class Assignment(Base):
|
||||
# ... существующие поля ...
|
||||
|
||||
# Для прохождений: привязка к игре вместо челленджа
|
||||
game_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("games.id"),
|
||||
nullable=True
|
||||
)
|
||||
is_playthrough: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
game: Mapped["Game"] = relationship(back_populates="playthrough_assignments")
|
||||
|
||||
# Отдельная таблица для бонусных челленджей
|
||||
class BonusAssignment(Base):
|
||||
__tablename__ = "bonus_assignments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
main_assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id"))
|
||||
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id"))
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending") # pending, completed
|
||||
proof_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
points_earned: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Relationships
|
||||
main_assignment: Mapped["Assignment"] = relationship(back_populates="bonus_assignments")
|
||||
challenge: Mapped["Challenge"] = relationship()
|
||||
```
|
||||
|
||||
### 6. API эндпоинты
|
||||
|
||||
Добавить/обновить эндпоинты:
|
||||
|
||||
```python
|
||||
# Обновить ответ спина
|
||||
class PlaythroughInfo(BaseModel):
|
||||
"""Информация о прохождении (для playthrough игр)"""
|
||||
description: str
|
||||
points: int
|
||||
|
||||
class SpinResult(BaseModel):
|
||||
assignment_id: int
|
||||
game: GameResponse
|
||||
challenge: ChallengeResponse | None # None для playthrough
|
||||
is_playthrough: bool
|
||||
playthrough_info: PlaythroughInfo | None # Заполняется для playthrough
|
||||
bonus_challenges: list[ChallengeResponse] = [] # Для playthrough
|
||||
can_drop: bool
|
||||
drop_penalty: int
|
||||
|
||||
# Завершение бонусного челленджа
|
||||
@router.post("/assignments/{assignment_id}/bonus/{challenge_id}/complete")
|
||||
async def complete_bonus_challenge(
|
||||
assignment_id: int,
|
||||
challenge_id: int,
|
||||
proof: ProofData,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> BonusAssignmentResponse:
|
||||
"""Завершить дополнительный челлендж для игры-прохождения"""
|
||||
...
|
||||
|
||||
# Получение бонусных челленджей
|
||||
@router.get("/assignments/{assignment_id}/bonus")
|
||||
async def get_bonus_assignments(
|
||||
assignment_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> list[BonusAssignmentResponse]:
|
||||
"""Получить список бонусных челленджей и их статус"""
|
||||
...
|
||||
|
||||
# Получение количества доступных игр для спина
|
||||
@router.get("/marathons/{marathon_id}/available-games-count")
|
||||
async def get_available_games_count(
|
||||
marathon_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Получить количество игр, доступных для спина.
|
||||
Возвращает: { "available": 5, "total": 10 }
|
||||
"""
|
||||
participant = await get_participant(...)
|
||||
marathon_games = await get_marathon_games(...)
|
||||
available = await get_available_games(participant, marathon_games, db)
|
||||
|
||||
return {
|
||||
"available": len(available),
|
||||
"total": len(marathon_games)
|
||||
}
|
||||
|
||||
# Редактирование игры
|
||||
@router.patch("/marathons/{marathon_id}/games/{game_id}")
|
||||
async def update_game(
|
||||
marathon_id: int,
|
||||
game_id: int,
|
||||
game_data: GameUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> GameResponse:
|
||||
"""
|
||||
Редактировать игру.
|
||||
|
||||
Доступно только организатору марафона.
|
||||
При смене типа на 'playthrough' необходимо указать playthrough_points и playthrough_description.
|
||||
"""
|
||||
# Проверка прав (организатор)
|
||||
# Валидация: если меняем тип на playthrough, проверить что поля заполнены
|
||||
# Обновление полей
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Изменения в Frontend
|
||||
|
||||
### 1. Типы (`frontend/src/types/index.ts`)
|
||||
|
||||
```typescript
|
||||
export type GameType = 'playthrough' | 'challenges'
|
||||
|
||||
export interface Game {
|
||||
// ... существующие поля ...
|
||||
game_type: GameType
|
||||
playthrough_points: number | null
|
||||
playthrough_description: string | null
|
||||
}
|
||||
|
||||
export interface PlaythroughInfo {
|
||||
description: string
|
||||
points: number
|
||||
}
|
||||
|
||||
export interface SpinResult {
|
||||
assignment_id: number
|
||||
game: Game
|
||||
challenge: Challenge | null // null для playthrough
|
||||
is_playthrough: boolean
|
||||
playthrough_info: PlaythroughInfo | null
|
||||
bonus_challenges: Challenge[]
|
||||
can_drop: boolean
|
||||
drop_penalty: number
|
||||
}
|
||||
|
||||
export interface BonusAssignment {
|
||||
id: number
|
||||
challenge: Challenge
|
||||
status: 'pending' | 'completed'
|
||||
proof_url: string | null
|
||||
completed_at: string | null
|
||||
points_earned: number
|
||||
}
|
||||
|
||||
export interface GameUpdate {
|
||||
title?: string
|
||||
download_url?: string
|
||||
genre?: string
|
||||
game_type?: GameType
|
||||
playthrough_points?: number
|
||||
playthrough_description?: string
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Форма добавления игры
|
||||
|
||||
Добавить выбор типа игры и условные поля:
|
||||
|
||||
```tsx
|
||||
// components/AddGameForm.tsx
|
||||
const [gameType, setGameType] = useState<GameType>('challenges')
|
||||
const [playthroughPoints, setPlaythroughPoints] = useState<number>(100)
|
||||
const [playthroughDescription, setPlaythroughDescription] = useState<string>('')
|
||||
|
||||
return (
|
||||
<form>
|
||||
{/* ... существующие поля ... */}
|
||||
|
||||
<Select
|
||||
label="Тип игры"
|
||||
value={gameType}
|
||||
onChange={setGameType}
|
||||
options={[
|
||||
{ value: 'challenges', label: 'Челленджи' },
|
||||
{ value: 'playthrough', label: 'Прохождение' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Поля только для типа "Прохождение" */}
|
||||
{gameType === 'playthrough' && (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
label="Очки за прохождение"
|
||||
value={playthroughPoints}
|
||||
onChange={setPlaythroughPoints}
|
||||
min={1}
|
||||
max={500}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Описание прохождения"
|
||||
value={playthroughDescription}
|
||||
onChange={setPlaythroughDescription}
|
||||
placeholder="Например: Пройти основной сюжет игры"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Отображение результата спина
|
||||
|
||||
Для типа "Прохождение" показывать:
|
||||
- Основное задание с описанием из `playthrough_info`
|
||||
- Очки за прохождение
|
||||
- Список дополнительных челленджей (опциональные)
|
||||
|
||||
```tsx
|
||||
// components/SpinResult.tsx
|
||||
{result.is_playthrough ? (
|
||||
<PlaythroughCard
|
||||
game={result.game}
|
||||
info={result.playthrough_info}
|
||||
bonusChallenges={result.bonus_challenges}
|
||||
/>
|
||||
) : (
|
||||
<ChallengeCard challenge={result.challenge} />
|
||||
)}
|
||||
```
|
||||
|
||||
### 4. Карточка текущего задания
|
||||
|
||||
Для playthrough показывать прогресс по доп. челленджам:
|
||||
|
||||
```tsx
|
||||
// components/CurrentAssignment.tsx
|
||||
{assignment.is_playthrough && (
|
||||
<div className="mt-4">
|
||||
<h4>Дополнительные задания (опционально)</h4>
|
||||
<BonusChallengesList
|
||||
assignmentId={assignment.id}
|
||||
challenges={assignment.bonus_challenges}
|
||||
onComplete={handleBonusComplete}
|
||||
/>
|
||||
<p className="text-sm text-gray-500">
|
||||
Выполнено: {completedCount} / {totalCount} (+{bonusPoints} очков)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 5. Форма завершения бонусного челленджа
|
||||
|
||||
```tsx
|
||||
// components/BonusChallengeCompleteModal.tsx
|
||||
<Modal>
|
||||
<h3>Завершить челлендж: {challenge.title}</h3>
|
||||
<p>{challenge.description}</p>
|
||||
<p>Очки: +{challenge.points}</p>
|
||||
|
||||
<ProofUpload
|
||||
proofType={challenge.proof_type}
|
||||
onUpload={handleProofUpload}
|
||||
/>
|
||||
|
||||
<Button onClick={handleComplete}>
|
||||
Завершить (+{challenge.points} очков)
|
||||
</Button>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
### 6. Редактирование игры
|
||||
|
||||
Добавить модалку/страницу редактирования игры:
|
||||
|
||||
```tsx
|
||||
// components/EditGameModal.tsx
|
||||
interface EditGameModalProps {
|
||||
game: Game
|
||||
onSave: (data: GameUpdate) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const EditGameModal = ({ game, onSave, onClose }: EditGameModalProps) => {
|
||||
const [title, setTitle] = useState(game.title)
|
||||
const [downloadUrl, setDownloadUrl] = useState(game.download_url)
|
||||
const [genre, setGenre] = useState(game.genre)
|
||||
const [gameType, setGameType] = useState<GameType>(game.game_type)
|
||||
const [playthroughPoints, setPlaythroughPoints] = useState(game.playthrough_points ?? 100)
|
||||
const [playthroughDescription, setPlaythroughDescription] = useState(game.playthrough_description ?? '')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: GameUpdate = {
|
||||
title,
|
||||
download_url: downloadUrl,
|
||||
genre,
|
||||
game_type: gameType,
|
||||
...(gameType === 'playthrough' && {
|
||||
playthrough_points: playthroughPoints,
|
||||
playthrough_description: playthroughDescription,
|
||||
}),
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<h2>Редактирование игры</h2>
|
||||
|
||||
<Input label="Название" value={title} onChange={setTitle} />
|
||||
<Input label="Ссылка на скачивание" value={downloadUrl} onChange={setDownloadUrl} />
|
||||
<Input label="Жанр" value={genre} onChange={setGenre} />
|
||||
|
||||
<Select
|
||||
label="Тип игры"
|
||||
value={gameType}
|
||||
onChange={setGameType}
|
||||
options={[
|
||||
{ value: 'challenges', label: 'Челленджи' },
|
||||
{ value: 'playthrough', label: 'Прохождение' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{gameType === 'playthrough' && (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
label="Очки за прохождение"
|
||||
value={playthroughPoints}
|
||||
onChange={setPlaythroughPoints}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
<Textarea
|
||||
label="Описание прохождения"
|
||||
value={playthroughDescription}
|
||||
onChange={setPlaythroughDescription}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>Отмена</Button>
|
||||
<Button onClick={handleSubmit}>Сохранить</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Кнопка редактирования в списке игр
|
||||
|
||||
```tsx
|
||||
// components/GameCard.tsx (или GamesList)
|
||||
{isOrganizer && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingGame(game)}
|
||||
>
|
||||
Редактировать
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
### 8. Счётчик доступных игр
|
||||
|
||||
Отображать количество игр, которые ещё могут выпасть при спине:
|
||||
|
||||
```tsx
|
||||
// components/AvailableGamesCounter.tsx
|
||||
interface AvailableGamesCounterProps {
|
||||
available: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const AvailableGamesCounter = ({ available, total }: AvailableGamesCounterProps) => {
|
||||
const allCompleted = available === 0
|
||||
|
||||
return (
|
||||
<div className="text-sm text-gray-500">
|
||||
{allCompleted ? (
|
||||
<span className="text-green-600 font-medium">
|
||||
Все игры пройдены!
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Доступно игр: <strong>{available}</strong> из {total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Использование на странице марафона / рядом с колесом
|
||||
<AvailableGamesCounter available={gamesCount.available} total={gamesCount.total} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Уточнённые требования
|
||||
|
||||
| Вопрос | Решение |
|
||||
|--------|---------|
|
||||
| Очки за прохождение | Устанавливаются при создании игры (поле `playthrough_points`) |
|
||||
| Обязательность доп. челленджей | **Не обязательны** — можно завершить задание без них |
|
||||
| Пруф на прохождение | Тип указывается при создании (`playthrough_proof_type`) |
|
||||
| Пруфы на бонусные челленджи | **Требуются** — по типу челленджа (screenshot/video/steam) |
|
||||
| Прикрепление файла | **Не обязательно** — можно отправить комментарий со ссылкой |
|
||||
| Миграция существующих игр | Тип по умолчанию: `challenges` |
|
||||
| Дроп игры (playthrough) | Дропнутая игра **не выпадает** повторно |
|
||||
| Бонусные челленджи после завершения | **Недоступны** — только пока задание активно |
|
||||
| Счётчик игр | Показывать "Доступно игр: X из Y" |
|
||||
| События для playthrough | **Все игнорируются** — стандартные очки без модификаторов |
|
||||
|
||||
---
|
||||
|
||||
## План реализации
|
||||
|
||||
### Этап 1: Backend (модели и миграции) ✅
|
||||
- [x] Добавить enum `GameType` в `backend/app/models/game.py`
|
||||
- [x] Добавить поля `game_type`, `playthrough_points`, `playthrough_description`, `playthrough_proof_type`, `playthrough_proof_hint` в модель Game
|
||||
- [x] Создать модель `BonusAssignment` в `backend/app/models/bonus_assignment.py`
|
||||
- [x] Обновить модель `Assignment` — добавить `game_id`, `is_playthrough`
|
||||
- [x] Создать миграцию Alembic (`020_add_game_types.py`)
|
||||
|
||||
### Этап 2: Backend (схемы и API) ✅
|
||||
- [x] Обновить Pydantic схемы для Game (`GameCreate`, `GameResponse`)
|
||||
- [x] Добавить схему `GameUpdate` с валидацией
|
||||
- [x] Обновить API создания игры
|
||||
- [x] Добавить API редактирования игры (`PATCH /games/{id}`)
|
||||
- [x] Добавить API счётчика игр (`GET /available-games-count`)
|
||||
- [x] Добавить схемы для `BonusAssignment`, `PlaythroughInfo`
|
||||
- [x] Добавить эндпоинты для бонусных челленджей
|
||||
|
||||
### Этап 3: Backend (логика спина) ✅
|
||||
- [x] Добавить функцию `get_available_games()` для фильтрации пройденных игр
|
||||
- [x] Обновить логику `spin_wheel` для обработки типов
|
||||
- [x] Для типа `challenges` — выбирать только невыполненные челленджи
|
||||
- [x] Обработать случай "Все игры пройдены"
|
||||
- [x] Обновить ответ SpinResult
|
||||
- [x] Обновить логику завершения задания для playthrough
|
||||
- [x] Добавить логику завершения бонусных челленджей
|
||||
- [x] Игнорирование событий для playthrough
|
||||
|
||||
### Этап 4: Frontend (типы и формы) ✅
|
||||
- [x] Обновить типы TypeScript (`Game`, `SpinResult`, `BonusAssignment`, `GameUpdate`, `AvailableGamesCount`)
|
||||
- [x] Добавить выбор типа в форму создания игры
|
||||
- [x] Добавить условные поля "Очки", "Описание", "Тип пруфа", "Подсказка" для типа "Прохождение"
|
||||
- [x] Добавить API метод `gamesApi.update()` и `gamesApi.getAvailableGamesCount()`
|
||||
- [x] Добавить API методы для бонусных челленджей
|
||||
|
||||
### Этап 5: Frontend (UI) ✅
|
||||
- [x] Обновить отображение результата спина для playthrough
|
||||
- [x] Обновить карточку текущего задания (PlayPage)
|
||||
- [x] Показ бонусных челленджей со статусами
|
||||
- [x] Бейдж "Прохождение" на карточках игр в лобби
|
||||
- [x] Поддержка пруфа через комментарий для playthrough
|
||||
|
||||
### Этап 6: Тестирование
|
||||
- [ ] Тестирование миграции на существующих данных
|
||||
- [ ] Проверка создания игр обоих типов
|
||||
- [ ] Проверка редактирования игр (смена типа, обновление полей)
|
||||
- [ ] Проверка спина для playthrough и challenges
|
||||
- [ ] Проверка фильтрации пройденных игр (playthrough не выпадает повторно)
|
||||
- [ ] Проверка фильтрации челленджей (выпадают только невыполненные)
|
||||
- [ ] Проверка состояния "Все игры пройдены"
|
||||
- [ ] Проверка завершения основного и бонусных заданий
|
||||
|
||||
---
|
||||
|
||||
## Схема работы
|
||||
|
||||
### Создание игры
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ СОЗДАНИЕ ИГРЫ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Выбор типа │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ "Прохождение" │ │ "Челленджи" │
|
||||
│ │ │ │
|
||||
│ Доп. поля: │ │ Стандартные │
|
||||
│ • Очки │ │ поля │
|
||||
│ • Описание │ │ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### Спин колеса
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ СПИН КОЛЕСА │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Выбор игры │
|
||||
│ (random) │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ game_type = │ │ game_type = │
|
||||
│ "playthrough" │ │ "challenges" │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Основное: │ │ Случайный │
|
||||
│ playthrough_ │ │ челлендж │
|
||||
│ description │ │ │
|
||||
│ │ │ (текущая │
|
||||
│ Очки: │ │ логика) │
|
||||
│ playthrough_ │ │ │
|
||||
│ points │ │ │
|
||||
│ │ │ │
|
||||
│ Доп. задания: │ │ │
|
||||
│ Все челленджи │ │ │
|
||||
│ (опционально) │ │ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Пруф: │ │ Пруф: │
|
||||
│ На прохождение │ │ По типу │
|
||||
│ игры │ │ челленджа │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Очки: │ │ Очки: │
|
||||
│ + За прохождение│ │ + За челлендж │
|
||||
│ + Бонус за доп. │ │ │
|
||||
│ челленджи │ │ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
Reference in New Issue
Block a user