Change rematch event to change game
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
"""Rename rematch event type to game_choice
|
||||||
|
|
||||||
|
Revision ID: 008_rename_to_game_choice
|
||||||
|
Revises: 007_add_event_assignment_fields
|
||||||
|
Create Date: 2024-12-15
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "008_rename_to_game_choice"
|
||||||
|
down_revision = "007_add_event_assignment_fields"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Update event type from 'rematch' to 'game_choice' in events table
|
||||||
|
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
|
||||||
|
|
||||||
|
# Update event_type in assignments table
|
||||||
|
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
|
||||||
|
|
||||||
|
# Update activity data that references rematch event
|
||||||
|
op.execute("""
|
||||||
|
UPDATE activities
|
||||||
|
SET data = jsonb_set(data, '{event_type}', '"game_choice"')
|
||||||
|
WHERE data->>'event_type' = 'rematch'
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Revert event type from 'game_choice' to 'rematch'
|
||||||
|
op.execute("UPDATE events SET type = 'rematch' WHERE type = 'game_choice'")
|
||||||
|
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
|
||||||
|
op.execute("""
|
||||||
|
UPDATE activities
|
||||||
|
SET data = jsonb_set(data, '{event_type}', '"rematch"')
|
||||||
|
WHERE data->>'event_type' = 'game_choice'
|
||||||
|
""")
|
||||||
@@ -640,129 +640,173 @@ async def cancel_swap_request(
|
|||||||
return MessageResponse(message="Swap request cancelled")
|
return MessageResponse(message="Swap request cancelled")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/rematch/{assignment_id}", response_model=MessageResponse)
|
# ==================== Game Choice Event Endpoints ====================
|
||||||
async def rematch_assignment(
|
|
||||||
|
|
||||||
|
class GameChoiceChallengeResponse(BaseModel):
|
||||||
|
"""Challenge option for game choice event"""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
difficulty: str
|
||||||
|
points: int
|
||||||
|
estimated_time: int | None
|
||||||
|
proof_type: str
|
||||||
|
proof_hint: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class GameChoiceChallengesResponse(BaseModel):
|
||||||
|
"""Response with available challenges for game choice"""
|
||||||
|
game_id: int
|
||||||
|
game_title: str
|
||||||
|
challenges: list[GameChoiceChallengeResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class GameChoiceSelectRequest(BaseModel):
|
||||||
|
"""Request to select a challenge during game choice event"""
|
||||||
|
challenge_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/game-choice/challenges", response_model=GameChoiceChallengesResponse)
|
||||||
|
async def get_game_choice_challenges(
|
||||||
marathon_id: int,
|
marathon_id: int,
|
||||||
assignment_id: int,
|
game_id: int,
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
):
|
):
|
||||||
"""Retry a dropped assignment (during rematch event)"""
|
"""Get 3 random challenges from a game for game choice event"""
|
||||||
|
from app.models import Game
|
||||||
|
from sqlalchemy.sql.expression import func
|
||||||
|
|
||||||
await get_marathon_or_404(db, marathon_id)
|
await get_marathon_or_404(db, marathon_id)
|
||||||
participant = await require_participant(db, current_user.id, marathon_id)
|
participant = await require_participant(db, current_user.id, marathon_id)
|
||||||
|
|
||||||
# Check active rematch event
|
# Check active game_choice event
|
||||||
event = await event_service.get_active_event(db, marathon_id)
|
event = await event_service.get_active_event(db, marathon_id)
|
||||||
if not event or event.type != EventType.REMATCH.value:
|
if not event or event.type != EventType.GAME_CHOICE.value:
|
||||||
raise HTTPException(status_code=400, detail="No active rematch event")
|
raise HTTPException(status_code=400, detail="No active game choice event")
|
||||||
|
|
||||||
# Check no current active assignment
|
# Get the game
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment).where(
|
select(Game).where(Game.id == game_id, Game.marathon_id == marathon_id)
|
||||||
Assignment.participant_id == participant.id,
|
|
||||||
Assignment.status == AssignmentStatus.ACTIVE.value,
|
|
||||||
)
|
)
|
||||||
)
|
game = result.scalar_one_or_none()
|
||||||
if result.scalar_one_or_none():
|
if not game:
|
||||||
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
|
||||||
# Get the dropped assignment
|
# Get 3 random challenges from this game
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.where(Challenge.game_id == game_id)
|
||||||
|
.order_by(func.random())
|
||||||
|
.limit(3)
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
if not challenges:
|
||||||
|
raise HTTPException(status_code=400, detail="No challenges available for this game")
|
||||||
|
|
||||||
|
return GameChoiceChallengesResponse(
|
||||||
|
game_id=game.id,
|
||||||
|
game_title=game.title,
|
||||||
|
challenges=[
|
||||||
|
GameChoiceChallengeResponse(
|
||||||
|
id=c.id,
|
||||||
|
title=c.title,
|
||||||
|
description=c.description,
|
||||||
|
difficulty=c.difficulty,
|
||||||
|
points=c.points,
|
||||||
|
estimated_time=c.estimated_time,
|
||||||
|
proof_type=c.proof_type,
|
||||||
|
proof_hint=c.proof_hint,
|
||||||
|
)
|
||||||
|
for c in challenges
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/marathons/{marathon_id}/game-choice/select", response_model=MessageResponse)
|
||||||
|
async def select_game_choice_challenge(
|
||||||
|
marathon_id: int,
|
||||||
|
data: GameChoiceSelectRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Select a challenge during game choice event (replaces current assignment if any)"""
|
||||||
|
await get_marathon_or_404(db, marathon_id)
|
||||||
|
participant = await require_participant(db, current_user.id, marathon_id)
|
||||||
|
|
||||||
|
# Check active game_choice event
|
||||||
|
event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
if not event or event.type != EventType.GAME_CHOICE.value:
|
||||||
|
raise HTTPException(status_code=400, detail="No active game choice event")
|
||||||
|
|
||||||
|
# Get the challenge
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.options(selectinload(Challenge.game))
|
||||||
|
.where(Challenge.id == data.challenge_id)
|
||||||
|
)
|
||||||
|
challenge = result.scalar_one_or_none()
|
||||||
|
if not challenge:
|
||||||
|
raise HTTPException(status_code=404, detail="Challenge not found")
|
||||||
|
|
||||||
|
# Verify challenge belongs to this marathon
|
||||||
|
if challenge.game.marathon_id != marathon_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Challenge does not belong to this marathon")
|
||||||
|
|
||||||
|
# Check for current active assignment (non-event)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(selectinload(Assignment.challenge))
|
.options(selectinload(Assignment.challenge))
|
||||||
.where(
|
.where(
|
||||||
Assignment.id == assignment_id,
|
|
||||||
Assignment.participant_id == participant.id,
|
Assignment.participant_id == participant.id,
|
||||||
Assignment.status == AssignmentStatus.DROPPED.value,
|
Assignment.status == AssignmentStatus.ACTIVE.value,
|
||||||
|
Assignment.is_event_assignment == False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
dropped = result.scalar_one_or_none()
|
current_assignment = result.scalar_one_or_none()
|
||||||
if not dropped:
|
|
||||||
raise HTTPException(status_code=404, detail="Dropped assignment not found")
|
|
||||||
|
|
||||||
# Create new assignment for the same challenge (with rematch event_type for 50% points)
|
# If there's a current assignment, replace it (free drop during this event)
|
||||||
|
old_challenge_title = None
|
||||||
|
if current_assignment:
|
||||||
|
old_challenge_title = current_assignment.challenge.title
|
||||||
|
# Mark old assignment as dropped (no penalty during game_choice event)
|
||||||
|
current_assignment.status = AssignmentStatus.DROPPED.value
|
||||||
|
current_assignment.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Create new assignment with chosen challenge
|
||||||
new_assignment = Assignment(
|
new_assignment = Assignment(
|
||||||
participant_id=participant.id,
|
participant_id=participant.id,
|
||||||
challenge_id=dropped.challenge_id,
|
challenge_id=data.challenge_id,
|
||||||
status=AssignmentStatus.ACTIVE.value,
|
status=AssignmentStatus.ACTIVE.value,
|
||||||
event_type=EventType.REMATCH.value,
|
event_type=EventType.GAME_CHOICE.value,
|
||||||
)
|
)
|
||||||
db.add(new_assignment)
|
db.add(new_assignment)
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
|
activity_data = {
|
||||||
|
"game": challenge.game.title,
|
||||||
|
"challenge": challenge.title,
|
||||||
|
"event_type": EventType.GAME_CHOICE.value,
|
||||||
|
}
|
||||||
|
if old_challenge_title:
|
||||||
|
activity_data["replaced_challenge"] = old_challenge_title
|
||||||
|
|
||||||
activity = Activity(
|
activity = Activity(
|
||||||
marathon_id=marathon_id,
|
marathon_id=marathon_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
type=ActivityType.REMATCH.value,
|
type=ActivityType.SPIN.value, # Treat as a spin activity
|
||||||
data={
|
data=activity_data,
|
||||||
"challenge": dropped.challenge.title,
|
|
||||||
"original_assignment_id": assignment_id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
db.add(activity)
|
db.add(activity)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message="Rematch started! Complete for 50% points")
|
if old_challenge_title:
|
||||||
|
return MessageResponse(message=f"Задание заменено! Теперь у вас: {challenge.title}")
|
||||||
|
else:
|
||||||
class DroppedAssignmentResponse(BaseModel):
|
return MessageResponse(message=f"Задание выбрано: {challenge.title}")
|
||||||
id: int
|
|
||||||
challenge: ChallengeResponse
|
|
||||||
dropped_at: datetime
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/dropped-assignments", response_model=list[DroppedAssignmentResponse])
|
|
||||||
async def get_dropped_assignments(
|
|
||||||
marathon_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbSession,
|
|
||||||
):
|
|
||||||
"""Get dropped assignments that can be rematched"""
|
|
||||||
await get_marathon_or_404(db, marathon_id)
|
|
||||||
participant = await require_participant(db, current_user.id, marathon_id)
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(Assignment)
|
|
||||||
.options(
|
|
||||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
Assignment.participant_id == participant.id,
|
|
||||||
Assignment.status == AssignmentStatus.DROPPED.value,
|
|
||||||
)
|
|
||||||
.order_by(Assignment.started_at.desc())
|
|
||||||
)
|
|
||||||
dropped = result.scalars().all()
|
|
||||||
|
|
||||||
return [
|
|
||||||
DroppedAssignmentResponse(
|
|
||||||
id=a.id,
|
|
||||||
challenge=ChallengeResponse(
|
|
||||||
id=a.challenge.id,
|
|
||||||
title=a.challenge.title,
|
|
||||||
description=a.challenge.description,
|
|
||||||
type=a.challenge.type,
|
|
||||||
difficulty=a.challenge.difficulty,
|
|
||||||
points=a.challenge.points,
|
|
||||||
estimated_time=a.challenge.estimated_time,
|
|
||||||
proof_type=a.challenge.proof_type,
|
|
||||||
proof_hint=a.challenge.proof_hint,
|
|
||||||
game=GameShort(
|
|
||||||
id=a.challenge.game.id,
|
|
||||||
title=a.challenge.game.title,
|
|
||||||
cover_url=None,
|
|
||||||
),
|
|
||||||
is_generated=a.challenge.is_generated,
|
|
||||||
created_at=a.challenge.created_at,
|
|
||||||
),
|
|
||||||
dropped_at=a.completed_at or a.started_at,
|
|
||||||
)
|
|
||||||
for a in dropped
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/swap-candidates", response_model=list[SwapCandidate])
|
@router.get("/marathons/{marathon_id}/swap-candidates", response_model=list[SwapCandidate])
|
||||||
|
|||||||
@@ -307,12 +307,12 @@ async def complete_assignment(
|
|||||||
# Check active event for point multipliers
|
# Check active event for point multipliers
|
||||||
active_event = await event_service.get_active_event(db, marathon_id)
|
active_event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
|
||||||
# For jackpot/rematch: use the event_type stored in assignment (since event may be over)
|
# For jackpot: use the event_type stored in assignment (since event may be over)
|
||||||
# For other events: use the currently active event
|
# For other events: use the currently active event
|
||||||
effective_event = active_event
|
effective_event = active_event
|
||||||
|
|
||||||
# Handle assignment-level event types (jackpot, rematch)
|
# Handle assignment-level event types (jackpot)
|
||||||
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]:
|
if assignment.event_type == EventType.JACKPOT.value:
|
||||||
# Create a mock event object for point calculation
|
# Create a mock event object for point calculation
|
||||||
class MockEvent:
|
class MockEvent:
|
||||||
def __init__(self, event_type):
|
def __init__(self, event_type):
|
||||||
@@ -353,8 +353,8 @@ async def complete_assignment(
|
|||||||
"points": total_points,
|
"points": total_points,
|
||||||
"streak": participant.current_streak,
|
"streak": participant.current_streak,
|
||||||
}
|
}
|
||||||
# Log event info (use assignment's event_type for jackpot/rematch, active_event for others)
|
# Log event info (use assignment's event_type for jackpot, active_event for others)
|
||||||
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.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
|
||||||
elif active_event:
|
elif active_event:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class ActivityType(str, Enum):
|
|||||||
EVENT_START = "event_start"
|
EVENT_START = "event_start"
|
||||||
EVENT_END = "event_end"
|
EVENT_END = "event_end"
|
||||||
SWAP = "swap"
|
SWAP = "swap"
|
||||||
REMATCH = "rematch"
|
GAME_CHOICE = "game_choice"
|
||||||
|
|
||||||
|
|
||||||
class Activity(Base):
|
class Activity(Base):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class EventType(str, Enum):
|
|||||||
DOUBLE_RISK = "double_risk" # дропы бесплатны, x0.5 очков
|
DOUBLE_RISK = "double_risk" # дропы бесплатны, x0.5 очков
|
||||||
JACKPOT = "jackpot" # x3 за сложный челлендж
|
JACKPOT = "jackpot" # x3 за сложный челлендж
|
||||||
SWAP = "swap" # обмен заданиями
|
SWAP = "swap" # обмен заданиями
|
||||||
REMATCH = "rematch" # реванш проваленного
|
GAME_CHOICE = "game_choice" # выбор игры (2-3 челленджа на выбор)
|
||||||
|
|
||||||
|
|
||||||
class Event(Base):
|
class Event(Base):
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ EventTypeLiteral = Literal[
|
|||||||
"double_risk",
|
"double_risk",
|
||||||
"jackpot",
|
"jackpot",
|
||||||
"swap",
|
"swap",
|
||||||
"rematch",
|
"game_choice",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ class EventCreate(BaseModel):
|
|||||||
class EventEffects(BaseModel):
|
class EventEffects(BaseModel):
|
||||||
points_multiplier: float = 1.0
|
points_multiplier: float = 1.0
|
||||||
drop_free: bool = False
|
drop_free: bool = False
|
||||||
special_action: str | None = None # "swap", "rematch"
|
special_action: str | None = None # "swap", "game_choice"
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ EVENT_INFO = {
|
|||||||
"drop_free": False,
|
"drop_free": False,
|
||||||
},
|
},
|
||||||
EventType.DOUBLE_RISK: {
|
EventType.DOUBLE_RISK: {
|
||||||
"name": "Двойной риск",
|
"name": "Безопасная игра",
|
||||||
"description": "Дропы бесплатны, но очки x0.5",
|
"description": "Дропы бесплатны, но очки x0.5",
|
||||||
"default_duration": 120,
|
"default_duration": 120,
|
||||||
"points_multiplier": 0.5,
|
"points_multiplier": 0.5,
|
||||||
@@ -106,13 +106,13 @@ EVENT_INFO = {
|
|||||||
"drop_free": False,
|
"drop_free": False,
|
||||||
"special_action": "swap",
|
"special_action": "swap",
|
||||||
},
|
},
|
||||||
EventType.REMATCH: {
|
EventType.GAME_CHOICE: {
|
||||||
"name": "Реванш",
|
"name": "Выбор игры",
|
||||||
"description": "Можно переделать проваленный челлендж за 50% очков",
|
"description": "Выбери игру и один из 3 челленджей. Можно заменить текущее задание без штрафа!",
|
||||||
"default_duration": 240,
|
"default_duration": 120,
|
||||||
"points_multiplier": 0.5,
|
"points_multiplier": 1.0,
|
||||||
"drop_free": False,
|
"drop_free": True, # Free replacement of current assignment
|
||||||
"special_action": "rematch",
|
"special_action": "game_choice",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ AUTO_EVENT_TYPES = [
|
|||||||
EventType.GOLDEN_HOUR,
|
EventType.GOLDEN_HOUR,
|
||||||
EventType.DOUBLE_RISK,
|
EventType.DOUBLE_RISK,
|
||||||
EventType.JACKPOT,
|
EventType.JACKPOT,
|
||||||
EventType.REMATCH,
|
EventType.GAME_CHOICE,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class PointsService:
|
|||||||
EventType.GOLDEN_HOUR.value: 1.5,
|
EventType.GOLDEN_HOUR.value: 1.5,
|
||||||
EventType.DOUBLE_RISK.value: 0.5,
|
EventType.DOUBLE_RISK.value: 0.5,
|
||||||
EventType.JACKPOT.value: 3.0,
|
EventType.JACKPOT.value: 3.0,
|
||||||
EventType.REMATCH.value: 0.5,
|
# GAME_CHOICE uses 1.0 multiplier (default)
|
||||||
}
|
}
|
||||||
|
|
||||||
def calculate_completion_points(
|
def calculate_completion_points(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult } from '@/types'
|
import type { ActiveEvent, MarathonEvent, EventCreate, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult, GameChoiceChallenges } from '@/types'
|
||||||
|
|
||||||
export const eventsApi = {
|
export const eventsApi = {
|
||||||
getActive: async (marathonId: number): Promise<ActiveEvent> => {
|
getActive: async (marathonId: number): Promise<ActiveEvent> => {
|
||||||
@@ -46,12 +46,18 @@ export const eventsApi = {
|
|||||||
await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`)
|
await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
rematch: async (marathonId: number, assignmentId: number): Promise<void> => {
|
// Game Choice event
|
||||||
await client.post(`/marathons/${marathonId}/rematch/${assignmentId}`)
|
getGameChoiceChallenges: async (marathonId: number, gameId: number): Promise<GameChoiceChallenges> => {
|
||||||
|
const response = await client.get<GameChoiceChallenges>(`/marathons/${marathonId}/game-choice/challenges`, {
|
||||||
|
params: { game_id: gameId },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
getDroppedAssignments: async (marathonId: number): Promise<DroppedAssignment[]> => {
|
selectGameChoiceChallenge: async (marathonId: number, challengeId: number): Promise<{ message: string }> => {
|
||||||
const response = await client.get<DroppedAssignment[]>(`/marathons/${marathonId}/dropped-assignments`)
|
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/game-choice/select`, {
|
||||||
|
challenge_id: challengeId,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Clock } from 'lucide-react'
|
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock } from 'lucide-react'
|
||||||
import type { ActiveEvent, EventType } from '@/types'
|
import type { ActiveEvent, EventType } from '@/types'
|
||||||
import { EVENT_INFO } from '@/types'
|
import { EVENT_INFO } from '@/types'
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
|||||||
double_risk: <Shield className="w-5 h-5" />,
|
double_risk: <Shield className="w-5 h-5" />,
|
||||||
jackpot: <Gift className="w-5 h-5" />,
|
jackpot: <Gift className="w-5 h-5" />,
|
||||||
swap: <ArrowLeftRight className="w-5 h-5" />,
|
swap: <ArrowLeftRight className="w-5 h-5" />,
|
||||||
rematch: <RotateCcw className="w-5 h-5" />,
|
game_choice: <Gamepad2 className="w-5 h-5" />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_COLORS: Record<EventType, string> = {
|
const EVENT_COLORS: Record<EventType, string> = {
|
||||||
@@ -23,7 +23,7 @@ const EVENT_COLORS: Record<EventType, string> = {
|
|||||||
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400',
|
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400',
|
||||||
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400',
|
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400',
|
||||||
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400',
|
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400',
|
||||||
rematch: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
|
game_choice: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Play, Square } from 'lucide-react'
|
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui'
|
import { Button } from '@/components/ui'
|
||||||
import { eventsApi } from '@/api'
|
import { eventsApi } from '@/api'
|
||||||
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
||||||
@@ -17,7 +17,7 @@ const EVENT_TYPES: EventType[] = [
|
|||||||
'double_risk',
|
'double_risk',
|
||||||
'jackpot',
|
'jackpot',
|
||||||
'swap',
|
'swap',
|
||||||
'rematch',
|
'game_choice',
|
||||||
'common_enemy',
|
'common_enemy',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
|||||||
double_risk: <Shield className="w-4 h-4" />,
|
double_risk: <Shield className="w-4 h-4" />,
|
||||||
jackpot: <Gift className="w-4 h-4" />,
|
jackpot: <Gift className="w-4 h-4" />,
|
||||||
swap: <ArrowLeftRight className="w-4 h-4" />,
|
swap: <ArrowLeftRight className="w-4 h-4" />,
|
||||||
rematch: <RotateCcw className="w-4 h-4" />,
|
game_choice: <Gamepad2 className="w-4 h-4" />,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventControl({
|
export function EventControl({
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api'
|
import { marathonsApi, wheelApi, gamesApi, eventsApi } from '@/api'
|
||||||
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, DroppedAssignment, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment } from '@/types'
|
import type { Marathon, Assignment, SpinResult, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges } from '@/types'
|
||||||
import { Button, Card, CardContent } from '@/components/ui'
|
import { Button, Card, CardContent } from '@/components/ui'
|
||||||
import { SpinWheel } from '@/components/SpinWheel'
|
import { SpinWheel } from '@/components/SpinWheel'
|
||||||
import { EventBanner } from '@/components/EventBanner'
|
import { EventBanner } from '@/components/EventBanner'
|
||||||
import { Loader2, Upload, X, RotateCcw, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react'
|
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft } from 'lucide-react'
|
||||||
|
|
||||||
export function PlayPage() {
|
export function PlayPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -26,10 +26,11 @@ export function PlayPage() {
|
|||||||
// Drop state
|
// Drop state
|
||||||
const [isDropping, setIsDropping] = useState(false)
|
const [isDropping, setIsDropping] = useState(false)
|
||||||
|
|
||||||
// Rematch state
|
// Game Choice state
|
||||||
const [droppedAssignments, setDroppedAssignments] = useState<DroppedAssignment[]>([])
|
const [selectedGameId, setSelectedGameId] = useState<number | null>(null)
|
||||||
const [isRematchLoading, setIsRematchLoading] = useState(false)
|
const [gameChoiceChallenges, setGameChoiceChallenges] = useState<GameChoiceChallenges | null>(null)
|
||||||
const [rematchingId, setRematchingId] = useState<number | null>(null)
|
const [isLoadingChallenges, setIsLoadingChallenges] = useState(false)
|
||||||
|
const [isSelectingChallenge, setIsSelectingChallenge] = useState(false)
|
||||||
|
|
||||||
// Swap state
|
// Swap state
|
||||||
const [swapCandidates, setSwapCandidates] = useState<SwapCandidate[]>([])
|
const [swapCandidates, setSwapCandidates] = useState<SwapCandidate[]>([])
|
||||||
@@ -59,12 +60,13 @@ export function PlayPage() {
|
|||||||
loadData()
|
loadData()
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
// Load dropped assignments when rematch event is active
|
// Reset game choice state when event changes or ends
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeEvent?.event?.type === 'rematch' && !currentAssignment) {
|
if (activeEvent?.event?.type !== 'game_choice') {
|
||||||
loadDroppedAssignments()
|
setSelectedGameId(null)
|
||||||
|
setGameChoiceChallenges(null)
|
||||||
}
|
}
|
||||||
}, [activeEvent?.event?.type, currentAssignment])
|
}, [activeEvent?.event?.type])
|
||||||
|
|
||||||
// Load swap candidates and requests when swap event is active
|
// Load swap candidates and requests when swap event is active
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,16 +88,17 @@ export function PlayPage() {
|
|||||||
}
|
}
|
||||||
}, [activeEvent?.event?.type])
|
}, [activeEvent?.event?.type])
|
||||||
|
|
||||||
const loadDroppedAssignments = async () => {
|
const loadGameChoiceChallenges = async (gameId: number) => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
setIsRematchLoading(true)
|
setIsLoadingChallenges(true)
|
||||||
try {
|
try {
|
||||||
const dropped = await eventsApi.getDroppedAssignments(parseInt(id))
|
const challenges = await eventsApi.getGameChoiceChallenges(parseInt(id), gameId)
|
||||||
setDroppedAssignments(dropped)
|
setGameChoiceChallenges(challenges)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load dropped assignments:', error)
|
console.error('Failed to load game choice challenges:', error)
|
||||||
|
alert('Не удалось загрузить челленджи для этой игры')
|
||||||
} finally {
|
} finally {
|
||||||
setIsRematchLoading(false)
|
setIsLoadingChallenges(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,21 +272,33 @@ export function PlayPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRematch = async (assignmentId: number) => {
|
const handleGameSelect = async (gameId: number) => {
|
||||||
|
setSelectedGameId(gameId)
|
||||||
|
await loadGameChoiceChallenges(gameId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChallengeSelect = async (challengeId: number) => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
if (!confirm('Начать реванш? Вы получите 50% от обычных очков за выполнение.')) return
|
const hasActiveAssignment = !!currentAssignment
|
||||||
|
const confirmMessage = hasActiveAssignment
|
||||||
|
? 'Выбрать этот челлендж? Текущее задание будет заменено без штрафа.'
|
||||||
|
: 'Выбрать этот челлендж?'
|
||||||
|
|
||||||
setRematchingId(assignmentId)
|
if (!confirm(confirmMessage)) return
|
||||||
|
|
||||||
|
setIsSelectingChallenge(true)
|
||||||
try {
|
try {
|
||||||
await eventsApi.rematch(parseInt(id), assignmentId)
|
const result = await eventsApi.selectGameChoiceChallenge(parseInt(id), challengeId)
|
||||||
alert('Реванш начат! Выполните задание за 50% очков.')
|
alert(result.message)
|
||||||
|
setSelectedGameId(null)
|
||||||
|
setGameChoiceChallenges(null)
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось начать реванш')
|
alert(error.response?.data?.detail || 'Не удалось выбрать челлендж')
|
||||||
} finally {
|
} finally {
|
||||||
setRematchingId(null)
|
setIsSelectingChallenge(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,9 +727,106 @@ export function PlayPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Game Choice section - show ABOVE spin wheel during game_choice event (works with or without assignment) */}
|
||||||
|
{activeEvent?.event?.type === 'game_choice' && (
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Gamepad2 className="w-5 h-5 text-orange-500" />
|
||||||
|
<h3 className="text-lg font-bold text-white">Выбор игры</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
Выберите игру и один из 3 челленджей. {currentAssignment ? 'Текущее задание будет заменено без штрафа!' : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Game selection */}
|
||||||
|
{!selectedGameId && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{games.map((game) => (
|
||||||
|
<button
|
||||||
|
key={game.id}
|
||||||
|
onClick={() => handleGameSelect(game.id)}
|
||||||
|
className="p-3 bg-gray-900 hover:bg-gray-800 rounded-lg text-left transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-white font-medium truncate">{game.title}</p>
|
||||||
|
<p className="text-gray-400 text-xs">{game.challenges_count} челленджей</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Challenge selection */}
|
||||||
|
{selectedGameId && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h4 className="text-white font-medium">
|
||||||
|
{gameChoiceChallenges?.game_title || 'Загрузка...'}
|
||||||
|
</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedGameId(null)
|
||||||
|
setGameChoiceChallenges(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingChallenges ? (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||||
|
</div>
|
||||||
|
) : gameChoiceChallenges?.challenges.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{gameChoiceChallenges.challenges.map((challenge) => (
|
||||||
|
<div
|
||||||
|
key={challenge.id}
|
||||||
|
className="p-4 bg-gray-900 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white font-medium">{challenge.title}</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">{challenge.description}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-xs">
|
||||||
|
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 rounded">
|
||||||
|
+{challenge.points} очков
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 bg-gray-700 text-gray-300 rounded">
|
||||||
|
{challenge.difficulty}
|
||||||
|
</span>
|
||||||
|
{challenge.estimated_time && (
|
||||||
|
<span className="text-gray-500">~{challenge.estimated_time} мин</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleChallengeSelect(challenge.id)}
|
||||||
|
isLoading={isSelectingChallenge}
|
||||||
|
disabled={isSelectingChallenge}
|
||||||
|
>
|
||||||
|
Выбрать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-gray-500 py-4">
|
||||||
|
Нет доступных челленджей для этой игры
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* No active assignment - show spin wheel */}
|
{/* No active assignment - show spin wheel */}
|
||||||
{!currentAssignment && (
|
{!currentAssignment && (
|
||||||
<>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-8">
|
<CardContent className="py-8">
|
||||||
<h2 className="text-2xl font-bold text-white mb-2 text-center">Крутите колесо!</h2>
|
<h2 className="text-2xl font-bold text-white mb-2 text-center">Крутите колесо!</h2>
|
||||||
@@ -728,56 +840,6 @@ export function PlayPage() {
|
|||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Rematch section - show during rematch event */}
|
|
||||||
{activeEvent?.event?.type === 'rematch' && droppedAssignments.length > 0 && (
|
|
||||||
<Card className="mt-6">
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<RotateCcw className="w-5 h-5 text-orange-500" />
|
|
||||||
<h3 className="text-lg font-bold text-white">Реванш</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-400 text-sm mb-4">
|
|
||||||
Во время события "Реванш" вы можете повторить пропущенные задания за 50% очков
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{isRematchLoading ? (
|
|
||||||
<div className="flex justify-center py-4">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{droppedAssignments.map((dropped) => (
|
|
||||||
<div
|
|
||||||
key={dropped.id}
|
|
||||||
className="flex items-center justify-between p-3 bg-gray-900 rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-white font-medium truncate">
|
|
||||||
{dropped.challenge.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-400 text-sm">
|
|
||||||
{dropped.challenge.game.title} • {dropped.challenge.points * 0.5} очков
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => handleRematch(dropped.id)}
|
|
||||||
isLoading={rematchingId === dropped.id}
|
|
||||||
disabled={rematchingId !== null}
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-4 h-4 mr-1" />
|
|
||||||
Реванш
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active assignment */}
|
{/* Active assignment */}
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ export type EventType =
|
|||||||
| 'double_risk'
|
| 'double_risk'
|
||||||
| 'jackpot'
|
| 'jackpot'
|
||||||
| 'swap'
|
| 'swap'
|
||||||
| 'rematch'
|
| 'game_choice'
|
||||||
|
|
||||||
export interface MarathonEvent {
|
export interface MarathonEvent {
|
||||||
id: number
|
id: number
|
||||||
@@ -334,7 +334,7 @@ export const EVENT_INFO: Record<EventType, { name: string; description: string;
|
|||||||
color: 'red',
|
color: 'red',
|
||||||
},
|
},
|
||||||
double_risk: {
|
double_risk: {
|
||||||
name: 'Двойной риск',
|
name: 'Безопасная игра',
|
||||||
description: 'Дропы бесплатны, но очки x0.5',
|
description: 'Дропы бесплатны, но очки x0.5',
|
||||||
color: 'purple',
|
color: 'purple',
|
||||||
},
|
},
|
||||||
@@ -348,13 +348,31 @@ export const EVENT_INFO: Record<EventType, { name: string; description: string;
|
|||||||
description: 'Можно поменяться заданием с другим участником',
|
description: 'Можно поменяться заданием с другим участником',
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
},
|
},
|
||||||
rematch: {
|
game_choice: {
|
||||||
name: 'Реванш',
|
name: 'Выбор игры',
|
||||||
description: 'Можно переделать проваленный челлендж за 50% очков',
|
description: 'Выбери игру и один из 3 челленджей. Можно заменить задание без штрафа!',
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Game Choice types
|
||||||
|
export interface GameChoiceChallenge {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
difficulty: Difficulty
|
||||||
|
points: number
|
||||||
|
estimated_time: number | null
|
||||||
|
proof_type: ProofType
|
||||||
|
proof_hint: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameChoiceChallenges {
|
||||||
|
game_id: number
|
||||||
|
game_title: string
|
||||||
|
challenges: GameChoiceChallenge[]
|
||||||
|
}
|
||||||
|
|
||||||
// Admin types
|
// Admin types
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -107,10 +107,10 @@ export function isEventActivity(type: ActivityType): boolean {
|
|||||||
const EVENT_NAMES: Record<EventType, string> = {
|
const EVENT_NAMES: Record<EventType, string> = {
|
||||||
golden_hour: 'Золотой час',
|
golden_hour: 'Золотой час',
|
||||||
common_enemy: 'Общий враг',
|
common_enemy: 'Общий враг',
|
||||||
double_risk: 'Двойной риск',
|
double_risk: 'Безопасная игра',
|
||||||
jackpot: 'Джекпот',
|
jackpot: 'Джекпот',
|
||||||
swap: 'Обмен',
|
swap: 'Обмен',
|
||||||
rematch: 'Реванш',
|
game_choice: 'Выбор игры',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Difficulty translation
|
// Difficulty translation
|
||||||
|
|||||||
Reference in New Issue
Block a user