- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
38 KiB
ТЗ: Типы игр "Прохождение" и "Челленджи"
Описание задачи
Добавить систему типов для игр, которая определяет логику выпадения заданий при спине колеса.
Два типа игр:
| Тип | Название | Поведение при выпадении |
|---|---|---|
playthrough |
Прохождение | Основное задание — пройти игру. Челленджи становятся дополнительными заданиями |
challenges |
Челленджи | Выдаётся случайный челлендж из списка челленджей игры (текущее поведение) |
Детальное описание логики
Тип "Прохождение" (playthrough)
При создании игры с типом "Прохождение" указываются дополнительные поля:
- Очки за прохождение (
playthrough_points) — количество очков за прохождение игры - Описание прохождения (
playthrough_description) — описание задания (например: "Пройти основной сюжет игры") - Тип пруфа (
playthrough_proof_type) — screenshot / video / steam - Подсказка для пруфа (
playthrough_proof_hint) — опционально (например: "Скриншот финальных титров")
При выпадении игры с типом "Прохождение":
- Основное задание: Пройти игру (очки и описание берутся из полей игры)
- Дополнительные задания: Все челленджи игры становятся опциональными бонусными заданиями
- Пруфы:
- Требуется отдельный пруф на прохождение игры (тип из
playthrough_proof_type) - Для каждого бонусного челленджа тоже требуется пруф (по типу челленджа)
- Прикрепление файла не обязательно — можно отправить только комментарий со ссылкой на видео
- Требуется отдельный пруф на прохождение игры (тип из
- Система очков:
- За основное прохождение —
playthrough_points(указанные при создании) - За каждый выполненный доп. челлендж — очки челленджа
- За основное прохождение —
- Завершение: Задание считается выполненным после прохождения основной игры. Доп. челленджи не обязательны — можно выполнять параллельно или игнорировать
Тип "Челленджи" (challenges)
При выпадении игры с типом "Челленджи":
- Выбирается один случайный челлендж из списка челленджей игры
- Участник выполняет только этот челлендж
- Логика остаётся без изменений (текущее поведение системы)
Фильтрация игр при спине
При выборе игры для спина необходимо исключать уже пройденные/дропнутые игры:
| Тип игры | Условие исключения из спина |
|---|---|
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)
Добавить поля для типа игры и прохождения:
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:
# 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
# Новая миграция
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:
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)
Обновить модель для поддержки прохождений:
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 эндпоинты
Добавить/обновить эндпоинты:
# Обновить ответ спина
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)
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. Форма добавления игры
Добавить выбор типа игры и условные поля:
// 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 - Очки за прохождение
- Список дополнительных челленджей (опциональные)
// components/SpinResult.tsx
{result.is_playthrough ? (
<PlaythroughCard
game={result.game}
info={result.playthrough_info}
bonusChallenges={result.bonus_challenges}
/>
) : (
<ChallengeCard challenge={result.challenge} />
)}
4. Карточка текущего задания
Для playthrough показывать прогресс по доп. челленджам:
// 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. Форма завершения бонусного челленджа
// 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. Редактирование игры
Добавить модалку/страницу редактирования игры:
// 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. Кнопка редактирования в списке игр
// components/GameCard.tsx (или GamesList)
{isOrganizer && (
<Button
variant="ghost"
size="sm"
onClick={() => setEditingGame(game)}
>
Редактировать
</Button>
)}
8. Счётчик доступных игр
Отображать количество игр, которые ещё могут выпасть при спине:
// 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 (модели и миграции) ✅
- Добавить enum
GameTypeвbackend/app/models/game.py - Добавить поля
game_type,playthrough_points,playthrough_description,playthrough_proof_type,playthrough_proof_hintв модель Game - Создать модель
BonusAssignmentвbackend/app/models/bonus_assignment.py - Обновить модель
Assignment— добавитьgame_id,is_playthrough - Создать миграцию Alembic (
020_add_game_types.py)
Этап 2: Backend (схемы и API) ✅
- Обновить Pydantic схемы для Game (
GameCreate,GameResponse) - Добавить схему
GameUpdateс валидацией - Обновить API создания игры
- Добавить API редактирования игры (
PATCH /games/{id}) - Добавить API счётчика игр (
GET /available-games-count) - Добавить схемы для
BonusAssignment,PlaythroughInfo - Добавить эндпоинты для бонусных челленджей
Этап 3: Backend (логика спина) ✅
- Добавить функцию
get_available_games()для фильтрации пройденных игр - Обновить логику
spin_wheelдля обработки типов - Для типа
challenges— выбирать только невыполненные челленджи - Обработать случай "Все игры пройдены"
- Обновить ответ SpinResult
- Обновить логику завершения задания для playthrough
- Добавить логику завершения бонусных челленджей
- Игнорирование событий для playthrough
Этап 4: Frontend (типы и формы) ✅
- Обновить типы TypeScript (
Game,SpinResult,BonusAssignment,GameUpdate,AvailableGamesCount) - Добавить выбор типа в форму создания игры
- Добавить условные поля "Очки", "Описание", "Тип пруфа", "Подсказка" для типа "Прохождение"
- Добавить API метод
gamesApi.update()иgamesApi.getAvailableGamesCount() - Добавить API методы для бонусных челленджей
Этап 5: Frontend (UI) ✅
- Обновить отображение результата спина для playthrough
- Обновить карточку текущего задания (PlayPage)
- Показ бонусных челленджей со статусами
- Бейдж "Прохождение" на карточках игр в лобби
- Поддержка пруфа через комментарий для playthrough
Этап 6: Тестирование
- Тестирование миграции на существующих данных
- Проверка создания игр обоих типов
- Проверка редактирования игр (смена типа, обновление полей)
- Проверка спина для playthrough и challenges
- Проверка фильтрации пройденных игр (playthrough не выпадает повторно)
- Проверка фильтрации челленджей (выпадают только невыполненные)
- Проверка состояния "Все игры пройдены"
- Проверка завершения основного и бонусных заданий
Схема работы
Создание игры
┌─────────────────────────────────────────────────────────────────┐
│ СОЗДАНИЕ ИГРЫ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ Выбор типа │
└─────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ "Прохождение" │ │ "Челленджи" │
│ │ │ │
│ Доп. поля: │ │ Стандартные │
│ • Очки │ │ поля │
│ • Описание │ │ │
└─────────────────┘ └─────────────────┘
Спин колеса
┌─────────────────────────────────────────────────────────────────┐
│ СПИН КОЛЕСА │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ Выбор игры │
│ (random) │
└─────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ game_type = │ │ game_type = │
│ "playthrough" │ │ "challenges" │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Основное: │ │ Случайный │
│ playthrough_ │ │ челлендж │
│ description │ │ │
│ │ │ (текущая │
│ Очки: │ │ логика) │
│ playthrough_ │ │ │
│ points │ │ │
│ │ │ │
│ Доп. задания: │ │ │
│ Все челленджи │ │ │
│ (опционально) │ │ │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Пруф: │ │ Пруф: │
│ На прохождение │ │ По типу │
│ игры │ │ челленджа │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Очки: │ │ Очки: │
│ + За прохождение│ │ + За челлендж │
│ + Бонус за доп. │ │ │
│ челленджи │ │ │
└─────────────────┘ └─────────────────┘