Merge branch 'master' into marathon-v2

This commit is contained in:
2026-01-08 05:37:27 +07:00
7 changed files with 245 additions and 44 deletions

View File

@@ -1335,17 +1335,31 @@ async def complete_bonus_assignment(
bonus_assignment.proof_url = proof_url
# Complete the bonus assignment
# NOTE: We store BASE points here. Event multiplier is applied when main assignment is completed
# This ensures multiplier is applied to the SUM (base + all bonuses), not separately
bonus_assignment.points_earned = bonus_assignment.challenge.points
bonus_assignment.status = BonusAssignmentStatus.COMPLETED.value
bonus_assignment.proof_comment = comment
bonus_assignment.points_earned = bonus_assignment.challenge.points
bonus_assignment.completed_at = datetime.utcnow()
# If main assignment is already COMPLETED, add bonus points immediately
# This handles the case where a bonus was disputed and user is re-completing it
if assignment.status == AssignmentStatus.COMPLETED.value:
from app.models import EventType
from app.services.points import PointsService
# Apply event multiplier if assignment had one
points_to_add = bonus_assignment.points_earned
if assignment.event_type:
ps = PointsService()
multiplier = ps.EVENT_MULTIPLIERS.get(assignment.event_type, 1.0)
points_to_add = int(bonus_assignment.points_earned * multiplier)
# Update bonus assignment to show multiplied points
bonus_assignment.points_earned = points_to_add
participant = assignment.participant
participant.total_points += bonus_assignment.points_earned
assignment.points_earned += bonus_assignment.points_earned
participant.total_points += points_to_add
assignment.points_earned += points_to_add
# NOTE: If main is not completed yet, points will be added when main is completed
# This prevents exploiting by dropping the main assignment after getting bonus points

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
Marathon, MarathonStatus, Participant, ParticipantRole,
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
)
from fastapi import UploadFile, File, Form
@@ -150,6 +150,46 @@ async def start_event(
detail="Common enemy event requires challenge_id"
)
# Handle playthrough games (negative challenge_id = -game_id)
challenge_id = data.challenge_id
game_id = None
is_playthrough = False
if data.type == EventType.COMMON_ENEMY.value and challenge_id and challenge_id < 0:
# This is a playthrough game, not a real challenge
game_id = -challenge_id # Convert negative to positive game_id
challenge_id = None
is_playthrough = True
# Verify game exists and is a playthrough game
from app.models.game import GameType
result = await db.execute(
select(Game).where(
Game.id == game_id,
Game.marathon_id == marathon_id,
Game.game_type == GameType.PLAYTHROUGH.value,
)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(
status_code=400,
detail="Playthrough game not found"
)
elif data.type == EventType.COMMON_ENEMY.value and challenge_id and challenge_id > 0:
# Verify regular challenge exists
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.where(Challenge.id == challenge_id)
)
challenge = result.scalar_one_or_none()
if not challenge or challenge.game.marathon_id != marathon_id:
raise HTTPException(
status_code=400,
detail="Challenge not found in this marathon"
)
try:
event = await event_service.start_event(
db=db,
@@ -157,7 +197,9 @@ async def start_event(
event_type=data.type,
created_by_id=current_user.id,
duration_minutes=data.duration_minutes,
challenge_id=data.challenge_id,
challenge_id=challenge_id,
game_id=game_id,
is_playthrough=is_playthrough,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -919,6 +961,41 @@ async def get_common_enemy_leaderboard(
def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
"""Convert Assignment model to AssignmentResponse"""
# Handle playthrough assignments (no challenge, only game)
if assignment.is_playthrough and assignment.game:
game = assignment.game
return AssignmentResponse(
id=assignment.id,
challenge=ChallengeResponse(
id=-game.id, # Negative ID for playthrough
title=f"Прохождение: {game.title}",
description=game.playthrough_description or "Пройдите игру",
type="completion",
difficulty="medium",
points=game.playthrough_points or 0,
estimated_time=None,
proof_type=game.playthrough_proof_type or "screenshot",
proof_hint=game.playthrough_proof_hint,
game=GameShort(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
game_type=game.game_type,
),
is_generated=False,
created_at=game.created_at,
),
status=assignment.status,
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
)
# Regular challenge assignment
challenge = assignment.challenge
game = challenge.game
return AssignmentResponse(
@@ -969,7 +1046,8 @@ async def get_event_assignment(
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough assignments
)
.where(
Assignment.participant_id == participant.id,
@@ -1000,10 +1078,19 @@ async def get_event_assignment(
is_completed=False,
)
# Determine challenge_id for response (negative for playthrough)
challenge_id_response = None
if event and event.data:
if event.data.get("is_playthrough"):
game_id = event.data.get("game_id")
challenge_id_response = -game_id if game_id else None
else:
challenge_id_response = event.data.get("challenge_id")
return EventAssignmentResponse(
assignment=assignment_to_response(assignment) if assignment else None,
event_id=event.id if event else None,
challenge_id=event.data.get("challenge_id") if event and event.data else None,
challenge_id=challenge_id_response,
is_completed=is_completed,
)
@@ -1027,6 +1114,7 @@ async def complete_event_assignment(
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough assignments
)
.where(Assignment.id == assignment_id)
)
@@ -1080,17 +1168,25 @@ async def complete_event_assignment(
assignment.proof_comment = comment
# Get marathon_id
marathon_id = assignment.challenge.game.marathon_id
# Get marathon_id and base points (handle playthrough vs regular challenge)
participant = assignment.participant
if assignment.is_playthrough and assignment.game:
marathon_id = assignment.game.marathon_id
base_points = assignment.game.playthrough_points or 0
challenge_title = f"Прохождение: {assignment.game.title}"
game_title = assignment.game.title
difficulty = "medium"
else:
challenge = assignment.challenge
marathon_id = challenge.game.marathon_id
base_points = challenge.points
challenge_title = challenge.title
game_title = challenge.game.title
difficulty = challenge.difficulty
# Get active event for bonus calculation
active_event = await event_service.get_active_event(db, marathon_id)
# Calculate base points (no streak bonus for event assignments)
participant = assignment.participant
challenge = assignment.challenge
base_points = challenge.points
# Handle common enemy bonus
common_enemy_bonus = 0
common_enemy_closed = False
@@ -1114,12 +1210,13 @@ async def complete_event_assignment(
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"game": game_title,
"challenge": challenge_title,
"difficulty": difficulty,
"points": total_points,
"event_type": EventType.COMMON_ENEMY.value,
"is_event_assignment": True,
"is_playthrough": assignment.is_playthrough,
}
if common_enemy_bonus:
activity_data["common_enemy_bonus"] = common_enemy_bonus

View File

@@ -163,10 +163,13 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
game = random.choice(available_games)
if game.game_type == GameType.PLAYTHROUGH.value:
# Playthrough game - no challenge selection, ignore events
# Playthrough game - no challenge selection
# Events that apply to playthrough: GOLDEN_HOUR, DOUBLE_RISK, COMMON_ENEMY
# Events that DON'T apply: JACKPOT (hard challenges only)
is_playthrough = True
challenge = None
active_event = None # Ignore events for playthrough
if active_event and active_event.type == EventType.JACKPOT.value:
active_event = None # Jackpot doesn't apply to playthrough
else:
# Challenges game - select random challenge
if not game.challenges:
@@ -203,7 +206,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
game_id=game.id,
is_playthrough=True,
status=AssignmentStatus.ACTIVE.value,
# No event_type for playthrough
event_type=active_event.type if active_event else None,
)
db.add(assignment)
await db.flush() # Get assignment.id for bonus assignments
@@ -226,6 +229,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
"points": game.playthrough_points,
"bonus_challenges_count": len(bonus_challenges),
}
if active_event:
activity_data["event_type"] = active_event.type
else:
# Regular challenge assignment
assignment = Assignment(
@@ -325,6 +330,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
],
can_drop=True,
drop_penalty=drop_penalty,
event_type=active_event.type if active_event else None,
)
else:
# Return challenge result
@@ -348,6 +354,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
is_playthrough=False,
can_drop=True,
drop_penalty=drop_penalty,
event_type=active_event.type if active_event else None,
)
@@ -373,9 +380,17 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
# Handle playthrough assignments
if assignment.is_playthrough:
game = assignment.game
active_event = None # No events for playthrough
# Use stored event_type for playthrough
# All events except JACKPOT apply (DOUBLE_RISK = free drop, others affect points)
playthrough_event = None
if assignment.event_type and assignment.event_type != EventType.JACKPOT.value:
class MockEvent:
def __init__(self, event_type):
self.type = event_type
playthrough_event = MockEvent(assignment.event_type)
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, None
participant.drop_count, game.playthrough_points, playthrough_event
)
# Build bonus challenges response
@@ -425,6 +440,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
completed_at=assignment.completed_at,
drop_penalty=drop_penalty,
bonus_challenges=bonus_responses,
event_type=assignment.event_type,
)
# Regular challenge assignment
@@ -459,6 +475,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
started_at=assignment.started_at,
completed_at=assignment.completed_at,
drop_penalty=drop_penalty,
event_type=assignment.event_type,
)
@@ -572,19 +589,37 @@ async def complete_assignment(
if assignment.is_playthrough:
game = assignment.game
marathon_id = game.marathon_id
base_points = game.playthrough_points
base_playthrough_points = game.playthrough_points
# No events for playthrough
total_points, streak_bonus, _ = points_service.calculate_completion_points(
base_points, participant.current_streak, None
)
# Calculate bonus points from completed bonus assignments
bonus_points = sum(
ba.points_earned for ba in assignment.bonus_assignments
# Calculate BASE bonus points from completed bonus assignments (before multiplier)
base_bonus_points = sum(
ba.challenge.points for ba in assignment.bonus_assignments
if ba.status == BonusAssignmentStatus.COMPLETED.value
)
total_points += bonus_points
# Total base = playthrough + all bonuses
total_base_points = base_playthrough_points + base_bonus_points
# Get event for playthrough (use stored event_type from assignment)
# All events except JACKPOT apply to playthrough
playthrough_event = None
if assignment.event_type and assignment.event_type != EventType.JACKPOT.value:
class MockEvent:
def __init__(self, event_type):
self.type = event_type
playthrough_event = MockEvent(assignment.event_type)
# Apply multiplier to the TOTAL (base + bonuses), then add streak bonus
total_points, streak_bonus, event_bonus = points_service.calculate_completion_points(
total_base_points, participant.current_streak, playthrough_event
)
# Update bonus assignments to reflect multiplied points for display
if playthrough_event:
multiplier = points_service.EVENT_MULTIPLIERS.get(playthrough_event.type, 1.0)
for ba in assignment.bonus_assignments:
if ba.status == BonusAssignmentStatus.COMPLETED.value:
ba.points_earned = int(ba.challenge.points * multiplier)
# Apply boost multiplier from consumable
boost_multiplier = consumables_service.get_active_boost_multiplier(participant)
@@ -623,8 +658,8 @@ async def complete_assignment(
"game": game.title,
"is_playthrough": True,
"points": total_points,
"base_points": base_points,
"bonus_points": bonus_points,
"base_points": base_playthrough_points,
"bonus_points": base_bonus_points,
"streak": participant.current_streak,
}
if is_redo:
@@ -633,6 +668,9 @@ async def complete_assignment(
activity_data["boost_multiplier"] = boost_multiplier
if coins_earned > 0:
activity_data["coins_earned"] = coins_earned
if playthrough_event:
activity_data["event_type"] = playthrough_event.type
activity_data["event_bonus"] = event_bonus
activity = Activity(
marathon_id=marathon_id,
@@ -836,9 +874,17 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
game = assignment.game
marathon_id = game.marathon_id
# No events for playthrough
# Use stored event_type for drop penalty calculation
# DOUBLE_RISK = free drop (0 penalty)
playthrough_event = None
if assignment.event_type and assignment.event_type != EventType.JACKPOT.value:
class MockEvent:
def __init__(self, event_type):
self.type = event_type
playthrough_event = MockEvent(assignment.event_type)
penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, None
participant.drop_count, game.playthrough_points, playthrough_event
)
# Check for shield - if active, no penalty
@@ -877,6 +923,9 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
}
if shield_used:
activity_data["shield_used"] = True
if playthrough_event:
activity_data["event_type"] = playthrough_event.type
activity_data["free_drop"] = True
activity = Activity(
marathon_id=marathon_id,