Bug fixes
This commit is contained in:
52
backend/alembic/versions/025_simplify_boost_consumable.py
Normal file
52
backend/alembic/versions/025_simplify_boost_consumable.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Simplify boost consumable - make it one-time instead of timed
|
||||
|
||||
Revision ID: 025_simplify_boost
|
||||
Revises: 024_seed_shop_items
|
||||
Create Date: 2026-01-08
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '025_simplify_boost'
|
||||
down_revision: Union[str, None] = '024_seed_shop_items'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [c['name'] for c in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add new boolean column for one-time boost
|
||||
if not column_exists('participants', 'has_active_boost'):
|
||||
op.add_column('participants', sa.Column('has_active_boost', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
# Remove old timed boost columns
|
||||
if column_exists('participants', 'active_boost_multiplier'):
|
||||
op.drop_column('participants', 'active_boost_multiplier')
|
||||
|
||||
if column_exists('participants', 'active_boost_expires_at'):
|
||||
op.drop_column('participants', 'active_boost_expires_at')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Restore old columns
|
||||
if not column_exists('participants', 'active_boost_multiplier'):
|
||||
op.add_column('participants', sa.Column('active_boost_multiplier', sa.Float(), nullable=True))
|
||||
|
||||
if not column_exists('participants', 'active_boost_expires_at'):
|
||||
op.add_column('participants', sa.Column('active_boost_expires_at', sa.DateTime(), nullable=True))
|
||||
|
||||
# Remove new column
|
||||
if column_exists('participants', 'has_active_boost'):
|
||||
op.drop_column('participants', 'has_active_boost')
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user