Bug fixes

This commit is contained in:
2026-01-08 06:51:15 +07:00
parent 4488a13808
commit 2874b64481
12 changed files with 434 additions and 54 deletions

View File

@@ -452,6 +452,8 @@ async def force_finish_marathon(
db: DbSession,
):
"""Force finish a marathon. Admin only."""
from app.services.coins import coins_service
require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
@@ -465,6 +467,24 @@ async def force_finish_marathon(
old_status = marathon.status
marathon.status = MarathonStatus.FINISHED.value
marathon.end_date = datetime.utcnow()
# Award coins for top 3 places (only in certified marathons)
if marathon.is_certified:
top_result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
.limit(3)
)
top_participants = top_result.scalars().all()
for place, participant in enumerate(top_participants, start=1):
if participant.total_points > 0:
await coins_service.award_marathon_place(
db, participant.user, marathon, place
)
await db.commit()
# Log action

View File

@@ -353,6 +353,8 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
from app.services.coins import coins_service
# Require organizer role
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
@@ -362,6 +364,24 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
marathon.status = MarathonStatus.FINISHED.value
# Award coins for top 3 places (only in certified marathons)
if marathon.is_certified:
# Get top 3 participants by total_points
top_result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
.limit(3)
)
top_participants = top_result.scalars().all()
for place, participant in enumerate(top_participants, start=1):
if participant.total_points > 0: # Only award if they have points
await coins_service.award_marathon_place(
db, participant.user, marathon, place
)
# Log activity
activity = Activity(
marathon_id=marathon_id,

View File

@@ -206,7 +206,7 @@ async def use_consumable(
effect_description = "Shield activated - next drop will be free"
elif data.item_code == "boost":
effect = await consumables_service.use_boost(db, current_user, participant, marathon)
effect_description = f"Boost x{effect['multiplier']} activated until {effect['expires_at']}"
effect_description = f"Boost x{effect['multiplier']} activated for next complete"
elif data.item_code == "reroll":
effect = await consumables_service.use_reroll(db, current_user, participant, marathon, assignment)
effect_description = "Assignment rerolled - you can spin again"
@@ -241,8 +241,10 @@ async def get_consumables_status(
participant = await require_participant(db, current_user.id, marathon_id)
# Get inventory counts
# Get inventory counts for all consumables
skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip")
shields_available = await consumables_service.get_consumable_count(db, current_user.id, "shield")
boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost")
rerolls_available = await consumables_service.get_consumable_count(db, current_user.id, "reroll")
# Calculate remaining skips for this marathon
@@ -254,10 +256,11 @@ async def get_consumables_status(
skips_available=skips_available,
skips_used=participant.skips_used,
skips_remaining=skips_remaining,
shields_available=shields_available,
has_shield=participant.has_shield,
boosts_available=boosts_available,
has_active_boost=participant.has_active_boost,
boost_multiplier=participant.active_boost_multiplier if participant.has_active_boost else None,
boost_expires_at=participant.active_boost_expires_at if participant.has_active_boost else None,
boost_multiplier=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
rerolls_available=rerolls_available,
)

View File

@@ -622,7 +622,7 @@ async def complete_assignment(
ba.points_earned = int(ba.challenge.points * multiplier)
# Apply boost multiplier from consumable
boost_multiplier = consumables_service.get_active_boost_multiplier(participant)
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier)
@@ -729,7 +729,7 @@ async def complete_assignment(
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Apply boost multiplier from consumable
boost_multiplier = consumables_service.get_active_boost_multiplier(participant)
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
if boost_multiplier > 1.0:
total_points = int(total_points * boost_multiplier)

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean, Float
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
@@ -31,8 +31,7 @@ class Participant(Base):
# Shop: consumables state
skips_used: Mapped[int] = mapped_column(Integer, default=0)
active_boost_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True)
active_boost_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
@@ -47,16 +46,3 @@ class Participant(Base):
@property
def is_organizer(self) -> bool:
return self.role == ParticipantRole.ORGANIZER.value
@property
def has_active_boost(self) -> bool:
"""Check if participant has an active boost"""
if self.active_boost_multiplier is None or self.active_boost_expires_at is None:
return False
return datetime.utcnow() < self.active_boost_expires_at
def get_boost_multiplier(self) -> float:
"""Get current boost multiplier (1.0 if no active boost)"""
if self.has_active_boost:
return self.active_boost_multiplier or 1.0
return 1.0

View File

@@ -192,8 +192,9 @@ class ConsumablesStatusResponse(BaseModel):
skips_available: int # From inventory
skips_used: int # In this marathon
skips_remaining: int | None # Based on marathon limit
has_shield: bool
has_active_boost: bool
boost_multiplier: float | None
boost_expires_at: datetime | None
shields_available: int # From inventory
has_shield: bool # Currently activated
boosts_available: int # From inventory
has_active_boost: bool # Currently activated (one-time for next complete)
boost_multiplier: float | None # 1.5 if boost active
rerolls_available: int # From inventory

View File

@@ -1,7 +1,7 @@
"""
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
"""
from datetime import datetime, timedelta
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,7 +17,6 @@ class ConsumablesService:
"""Service for consumable items"""
# Boost settings
BOOST_DURATION_HOURS = 2
BOOST_MULTIPLIER = 1.5
async def use_skip(
@@ -141,10 +140,10 @@ class ConsumablesService:
marathon: Marathon,
) -> dict:
"""
Activate a Boost - multiplies points for next 2 hours.
Activate a Boost - multiplies points for NEXT complete only.
- Points for completed challenges are multiplied by BOOST_MULTIPLIER
- Duration: BOOST_DURATION_HOURS
- Points for next completed challenge are multiplied by BOOST_MULTIPLIER
- One-time use (consumed on next complete)
Returns: dict with result info
@@ -155,17 +154,13 @@ class ConsumablesService:
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
if participant.has_active_boost:
raise HTTPException(
status_code=400,
detail=f"Boost already active until {participant.active_boost_expires_at}"
)
raise HTTPException(status_code=400, detail="Boost is already activated")
# Consume boost from inventory
item = await self._consume_item(db, user, ConsumableType.BOOST.value)
# Activate boost
participant.active_boost_multiplier = self.BOOST_MULTIPLIER
participant.active_boost_expires_at = datetime.utcnow() + timedelta(hours=self.BOOST_DURATION_HOURS)
# Activate boost (one-time use)
participant.has_active_boost = True
# Log usage
usage = ConsumableUsage(
@@ -175,8 +170,7 @@ class ConsumablesService:
effect_data={
"type": "boost",
"multiplier": self.BOOST_MULTIPLIER,
"duration_hours": self.BOOST_DURATION_HOURS,
"expires_at": participant.active_boost_expires_at.isoformat(),
"one_time": True,
},
)
db.add(usage)
@@ -185,7 +179,6 @@ class ConsumablesService:
"success": True,
"boost_activated": True,
"multiplier": self.BOOST_MULTIPLIER,
"expires_at": participant.active_boost_expires_at,
}
async def use_reroll(
@@ -299,7 +292,7 @@ class ConsumablesService:
quantity = result.scalar_one_or_none()
return quantity or 0
def consume_shield_on_drop(self, participant: Participant) -> bool:
def consume_shield(self, participant: Participant) -> bool:
"""
Consume shield when dropping (called from wheel.py).
@@ -310,13 +303,17 @@ class ConsumablesService:
return True
return False
def get_active_boost_multiplier(self, participant: Participant) -> float:
def consume_boost_on_complete(self, participant: Participant) -> float:
"""
Get current boost multiplier for participant.
Consume boost when completing assignment (called from wheel.py).
One-time use - boost is consumed after single complete.
Returns: Multiplier value (1.0 if no active boost)
Returns: Multiplier value (BOOST_MULTIPLIER if boost was active, 1.0 otherwise)
"""
return participant.get_boost_multiplier()
if participant.has_active_boost:
participant.has_active_boost = False
return self.BOOST_MULTIPLIER
return 1.0
# Singleton instance