From 0b3837b08e02e13f3069f76f3eadcf8b0c1108d0 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Wed, 17 Dec 2025 20:19:26 +0700 Subject: [PATCH] Zaebalsya --- backend/app/api/v1/challenges.py | 44 +++++++--- backend/app/schemas/__init__.py | 2 + backend/app/schemas/challenge.py | 5 ++ frontend/src/api/games.ts | 5 +- frontend/src/pages/LobbyPage.tsx | 127 +++++++++++++++++++++++++--- frontend/src/pages/MarathonPage.tsx | 119 ++++++++++++++++++++++++-- 6 files changed, 267 insertions(+), 35 deletions(-) diff --git a/backend/app/api/v1/challenges.py b/backend/app/api/v1/challenges.py index 756e50c..2a69966 100644 --- a/backend/app/api/v1/challenges.py +++ b/backend/app/api/v1/challenges.py @@ -13,6 +13,7 @@ from app.schemas import ( ChallengePreview, ChallengesPreviewResponse, ChallengesSaveRequest, + ChallengesGenerateRequest, ) from app.services.gpt import gpt_service @@ -187,7 +188,12 @@ async def create_challenge( @router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse) -async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): +async def preview_challenges( + marathon_id: int, + current_user: CurrentUser, + db: DbSession, + data: ChallengesGenerateRequest | None = None, +): """Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only.""" # Check marathon result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) @@ -202,31 +208,45 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db await require_organizer(db, current_user, marathon_id) # Get only APPROVED games - result = await db.execute( - select(Game).where( - Game.marathon_id == marathon_id, - Game.status == GameStatus.APPROVED.value, - ) + query = select(Game).where( + Game.marathon_id == marathon_id, + Game.status == GameStatus.APPROVED.value, ) + + # Filter by specific game IDs if provided + if data and data.game_ids: + query = query.where(Game.id.in_(data.game_ids)) + + result = await db.execute(query) games = result.scalars().all() if not games: - raise HTTPException(status_code=400, detail="No approved games in marathon") + raise HTTPException(status_code=400, detail="No approved games found") - # Filter games that don't have challenges yet + # Build games list for generation (skip games that already have challenges, unless specific IDs requested) games_to_generate = [] game_map = {} for game in games: - existing = await db.scalar( - select(Challenge.id).where(Challenge.game_id == game.id).limit(1) - ) - if not existing: + # If specific games requested, generate even if they have challenges + if data and data.game_ids: games_to_generate.append({ "id": game.id, "title": game.title, "genre": game.genre }) game_map[game.id] = game.title + else: + # Otherwise only generate for games without challenges + existing = await db.scalar( + select(Challenge.id).where(Challenge.game_id == game.id).limit(1) + ) + if not existing: + games_to_generate.append({ + "id": game.id, + "title": game.title, + "genre": game.genre + }) + game_map[game.id] = game.title if not games_to_generate: return ChallengesPreviewResponse(challenges=[]) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index eb4d216..b84875d 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -37,6 +37,7 @@ from app.schemas.challenge import ( ChallengesPreviewResponse, ChallengeSaveItem, ChallengesSaveRequest, + ChallengesGenerateRequest, ) from app.schemas.assignment import ( CompleteAssignment, @@ -118,6 +119,7 @@ __all__ = [ "ChallengesPreviewResponse", "ChallengeSaveItem", "ChallengesSaveRequest", + "ChallengesGenerateRequest", # Assignment "CompleteAssignment", "AssignmentResponse", diff --git a/backend/app/schemas/challenge.py b/backend/app/schemas/challenge.py index aa7bdda..1a88436 100644 --- a/backend/app/schemas/challenge.py +++ b/backend/app/schemas/challenge.py @@ -88,3 +88,8 @@ class ChallengeSaveItem(BaseModel): class ChallengesSaveRequest(BaseModel): """Request to save previewed challenges""" challenges: list[ChallengeSaveItem] + + +class ChallengesGenerateRequest(BaseModel): + """Request to generate challenges for specific games""" + game_ids: list[int] | None = None # If None, generate for all approved games without challenges diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index ed68189..236d6fd 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -79,8 +79,9 @@ export const gamesApi = { await client.delete(`/challenges/${id}`) }, - previewChallenges: async (marathonId: number): Promise => { - const response = await client.post(`/marathons/${marathonId}/preview-challenges`) + previewChallenges: async (marathonId: number, gameIds?: number[]): Promise => { + const data = gameIds?.length ? { game_ids: gameIds } : undefined + const response = await client.post(`/marathons/${marathonId}/preview-challenges`, data) return response.data }, diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 35b6204..da5671a 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -39,6 +39,8 @@ export function LobbyPage() { const [previewChallenges, setPreviewChallenges] = useState(null) const [isSaving, setIsSaving] = useState(false) const [editingIndex, setEditingIndex] = useState(null) + const [showGenerateSelection, setShowGenerateSelection] = useState(false) + const [selectedGamesForGeneration, setSelectedGamesForGeneration] = useState([]) // View existing challenges const [expandedGameId, setExpandedGameId] = useState(null) @@ -253,11 +255,14 @@ export function LobbyPage() { setIsGenerating(true) setGenerateMessage(null) try { - const result = await gamesApi.previewChallenges(parseInt(id)) + // Pass selected games if any, otherwise generate for all games without challenges + const gameIds = selectedGamesForGeneration.length > 0 ? selectedGamesForGeneration : undefined + const result = await gamesApi.previewChallenges(parseInt(id), gameIds) if (result.challenges.length === 0) { - setGenerateMessage('Все игры уже имеют задания') + setGenerateMessage('Нет игр для генерации заданий') } else { setPreviewChallenges(result.challenges) + setShowGenerateSelection(false) } } catch (error) { console.error('Failed to generate challenges:', error) @@ -267,6 +272,22 @@ export function LobbyPage() { } } + const toggleGameSelection = (gameId: number) => { + setSelectedGamesForGeneration(prev => + prev.includes(gameId) + ? prev.filter(id => id !== gameId) + : [...prev, gameId] + ) + } + + const selectAllGamesForGeneration = () => { + setSelectedGamesForGeneration(approvedGames.map(g => g.id)) + } + + const clearGameSelection = () => { + setSelectedGamesForGeneration([]) + } + const handleSaveChallenges = async () => { if (!id || !previewChallenges) return @@ -703,7 +724,7 @@ export function LobbyPage() { {/* Generate challenges */} {isOrganizer && approvedGames.length > 0 && !previewChallenges && ( -
+
@@ -711,20 +732,100 @@ export function LobbyPage() {

Генерация заданий

- ИИ создаст задания для игр без заданий + {showGenerateSelection + ? `Выбрано: ${selectedGamesForGeneration.length} из ${approvedGames.length}` + : 'Выберите игры для генерации'}

- } - > - Сгенерировать - +
+ {showGenerateSelection ? ( + <> + { + setShowGenerateSelection(false) + clearGameSelection() + }} + variant="secondary" + size="sm" + > + Отмена + + } + disabled={selectedGamesForGeneration.length === 0} + > + Сгенерировать ({selectedGamesForGeneration.length}) + + + ) : ( + setShowGenerateSelection(true)} + variant="outline" + color="purple" + icon={} + > + Выбрать игры + + )} +
+ + {/* Game selection */} + {showGenerateSelection && ( +
+
+ + +
+
+ {approvedGames.map((game) => { + const isSelected = selectedGamesForGeneration.includes(game.id) + const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count + return ( + + ) + })} +
+
+ )} + {generateMessage && (

{generateMessage} diff --git a/frontend/src/pages/MarathonPage.tsx b/frontend/src/pages/MarathonPage.tsx index 0c9456a..16af9f6 100644 --- a/frontend/src/pages/MarathonPage.tsx +++ b/frontend/src/pages/MarathonPage.tsx @@ -12,7 +12,7 @@ import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed' import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag, - Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2 + Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles } from 'lucide-react' import { format } from 'date-fns' import { ru } from 'date-fns/locale' @@ -32,6 +32,8 @@ export function MarathonPage() { const [isJoining, setIsJoining] = useState(false) const [isFinishing, setIsFinishing] = useState(false) const [showEventControl, setShowEventControl] = useState(false) + const [showChallenges, setShowChallenges] = useState(false) + const [expandedGameId, setExpandedGameId] = useState(null) const activityFeedRef = useRef(null) useEffect(() => { @@ -48,13 +50,12 @@ export function MarathonPage() { const eventData = await eventsApi.getActive(parseInt(id)) setActiveEvent(eventData) - if (data.my_participation.role === 'organizer') { - try { - const challengesData = await challengesApi.list(parseInt(id)) - setChallenges(challengesData) - } catch { - // Ignore if no challenges - } + // Load challenges for all participants + try { + const challengesData = await challengesApi.list(parseInt(id)) + setChallenges(challengesData) + } catch { + // Ignore if no challenges } } } catch (error) { @@ -411,6 +412,108 @@ export function MarathonPage() {

)} + + {/* All challenges viewer */} + {marathon.status === 'active' && isParticipant && challenges.length > 0 && ( + + + {showChallenges && ( +
+ {/* Group challenges by game */} + {Array.from(new Set(challenges.map(c => c.game.id))).map(gameId => { + const gameChallenges = challenges.filter(c => c.game.id === gameId) + const game = gameChallenges[0]?.game + if (!game) return null + const isExpanded = expandedGameId === gameId + + return ( +
+ + + {isExpanded && ( +
+ {gameChallenges.map(challenge => ( +
+
+ + {challenge.difficulty === 'easy' ? 'Легко' : + challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'} + + + +{challenge.points} + + + {challenge.type === 'completion' ? 'Прохождение' : + challenge.type === 'no_death' ? 'Без смертей' : + challenge.type === 'speedrun' ? 'Спидран' : + challenge.type === 'collection' ? 'Коллекция' : + challenge.type === 'achievement' ? 'Достижение' : 'Челлендж-ран'} + +
+
{challenge.title}
+

{challenge.description}

+ {challenge.proof_hint && ( +

+ + Пруф: {challenge.proof_hint} +

+ )} +
+ ))} +
+ )} +
+ ) + })} +
+ )} +
+ )}
{/* Activity Feed - right sidebar */}