rework shop
This commit is contained in:
@@ -450,13 +450,13 @@ def upgrade() -> None:
|
||||
'item_type': 'consumable',
|
||||
'code': 'boost',
|
||||
'name': 'Буст x1.5',
|
||||
'description': 'Множитель очков x1.5 на следующие 2 часа',
|
||||
'description': 'Множитель очков x1.5 на текущее задание',
|
||||
'price': 200,
|
||||
'rarity': 'rare',
|
||||
'asset_data': {
|
||||
'effect': 'boost',
|
||||
'multiplier': 1.5,
|
||||
'duration_hours': 2,
|
||||
'one_time': True,
|
||||
'icon': 'zap'
|
||||
},
|
||||
'is_active': True,
|
||||
|
||||
46
backend/alembic/versions/026_update_boost_description.py
Normal file
46
backend/alembic/versions/026_update_boost_description.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Update boost description to one-time usage
|
||||
|
||||
Revision ID: 026_update_boost_desc
|
||||
Revises: 025_simplify_boost
|
||||
Create Date: 2026-01-08
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '026_update_boost_desc'
|
||||
down_revision: Union[str, None] = '025_simplify_boost'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Update boost description in shop_items table
|
||||
op.execute("""
|
||||
UPDATE shop_items
|
||||
SET description = 'Множитель очков x1.5 на текущее задание',
|
||||
asset_data = jsonb_set(
|
||||
asset_data::jsonb - 'duration_hours',
|
||||
'{one_time}',
|
||||
'true'
|
||||
)
|
||||
WHERE code = 'boost' AND item_type = 'consumable'
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Revert boost description
|
||||
op.execute("""
|
||||
UPDATE shop_items
|
||||
SET description = 'Множитель очков x1.5 на следующие 2 часа',
|
||||
asset_data = jsonb_set(
|
||||
asset_data::jsonb - 'one_time',
|
||||
'{duration_hours}',
|
||||
'2'
|
||||
)
|
||||
WHERE code = 'boost' AND item_type = 'consumable'
|
||||
""")
|
||||
83
backend/alembic/versions/027_consumables_redesign.py
Normal file
83
backend/alembic/versions/027_consumables_redesign.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Consumables redesign: remove shield/reroll, add wild_card/lucky_dice/copycat/undo
|
||||
|
||||
Revision ID: 027_consumables_redesign
|
||||
Revises: 026_update_boost_desc
|
||||
Create Date: 2026-01-08
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from datetime import datetime
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '027_consumables_redesign'
|
||||
down_revision: Union[str, None] = '026_update_boost_desc'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Remove has_shield column from participants
|
||||
op.drop_column('participants', 'has_shield')
|
||||
|
||||
# 2. Add new columns for lucky_dice and undo
|
||||
op.add_column('participants', sa.Column('has_lucky_dice', sa.Boolean(), nullable=False, server_default='false'))
|
||||
op.add_column('participants', sa.Column('lucky_dice_multiplier', sa.Float(), nullable=True))
|
||||
op.add_column('participants', sa.Column('last_drop_points', sa.Integer(), nullable=True))
|
||||
op.add_column('participants', sa.Column('last_drop_streak_before', sa.Integer(), nullable=True))
|
||||
op.add_column('participants', sa.Column('can_undo', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
# 3. Remove old consumables from shop
|
||||
op.execute("DELETE FROM shop_items WHERE code IN ('reroll', 'shield')")
|
||||
|
||||
# 4. Update boost price from 200 to 150
|
||||
op.execute("UPDATE shop_items SET price = 150 WHERE code = 'boost'")
|
||||
|
||||
# 5. Add new consumables to shop
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
op.execute(f"""
|
||||
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
|
||||
VALUES
|
||||
('consumable', 'wild_card', 'Дикая карта', 'Выбери игру и получи случайное задание из неё', 150, 'uncommon',
|
||||
'{{"effect": "wild_card", "icon": "shuffle"}}', true, '{now}'),
|
||||
('consumable', 'lucky_dice', 'Счастливые кости', 'Случайный множитель очков (1.5x - 4.0x)', 250, 'rare',
|
||||
'{{"effect": "lucky_dice", "multipliers": [1.5, 2.0, 2.5, 3.0, 3.5, 4.0], "icon": "dice"}}', true, '{now}'),
|
||||
('consumable', 'copycat', 'Копикэт', 'Скопируй задание любого участника марафона', 300, 'epic',
|
||||
'{{"effect": "copycat", "icon": "copy"}}', true, '{now}'),
|
||||
('consumable', 'undo', 'Отмена', 'Отмени последний дроп и верни очки со стриком', 300, 'epic',
|
||||
'{{"effect": "undo", "icon": "undo"}}', true, '{now}')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. Remove new columns
|
||||
op.drop_column('participants', 'can_undo')
|
||||
op.drop_column('participants', 'last_drop_streak_before')
|
||||
op.drop_column('participants', 'last_drop_points')
|
||||
op.drop_column('participants', 'lucky_dice_multiplier')
|
||||
op.drop_column('participants', 'has_lucky_dice')
|
||||
|
||||
# 2. Add back has_shield
|
||||
op.add_column('participants', sa.Column('has_shield', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
# 3. Remove new consumables
|
||||
op.execute("DELETE FROM shop_items WHERE code IN ('wild_card', 'lucky_dice', 'copycat', 'undo')")
|
||||
|
||||
# 4. Restore boost price back to 200
|
||||
op.execute("UPDATE shop_items SET price = 200 WHERE code = 'boost'")
|
||||
|
||||
# 5. Add back old consumables
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
op.execute(f"""
|
||||
INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at)
|
||||
VALUES
|
||||
('consumable', 'shield', 'Щит', 'Защита от штрафа при следующем дропе. Streak сохраняется.', 150, 'uncommon',
|
||||
'{{"effect": "shield", "icon": "shield"}}', true, '{now}'),
|
||||
('consumable', 'reroll', 'Перекрут', 'Перекрутить колесо и получить новое задание', 80, 'common',
|
||||
'{{"effect": "reroll", "icon": "refresh-cw"}}', true, '{now}')
|
||||
""")
|
||||
@@ -181,18 +181,29 @@ async def use_consumable(
|
||||
# Get participant
|
||||
participant = await require_participant(db, current_user.id, data.marathon_id)
|
||||
|
||||
# For skip and reroll, we need the assignment
|
||||
# For some consumables, we need the assignment
|
||||
assignment = None
|
||||
if data.item_code in ["skip", "reroll"]:
|
||||
if data.item_code in ["skip", "wild_card", "copycat"]:
|
||||
if not data.assignment_id:
|
||||
raise HTTPException(status_code=400, detail="assignment_id is required for skip/reroll")
|
||||
raise HTTPException(status_code=400, detail=f"assignment_id is required for {data.item_code}")
|
||||
|
||||
result = await db.execute(
|
||||
select(Assignment).where(
|
||||
Assignment.id == data.assignment_id,
|
||||
Assignment.participant_id == participant.id,
|
||||
# For copycat, we need bonus_assignments to properly handle playthrough
|
||||
if data.item_code == "copycat":
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(selectinload(Assignment.bonus_assignments))
|
||||
.where(
|
||||
Assignment.id == data.assignment_id,
|
||||
Assignment.participant_id == participant.id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(Assignment).where(
|
||||
Assignment.id == data.assignment_id,
|
||||
Assignment.participant_id == participant.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
@@ -201,15 +212,29 @@ async def use_consumable(
|
||||
if data.item_code == "skip":
|
||||
effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment)
|
||||
effect_description = "Assignment skipped without penalty"
|
||||
elif data.item_code == "shield":
|
||||
effect = await consumables_service.use_shield(db, current_user, participant, marathon)
|
||||
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 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"
|
||||
effect_description = f"Boost x{effect['multiplier']} activated for current assignment"
|
||||
elif data.item_code == "wild_card":
|
||||
if data.game_id is None:
|
||||
raise HTTPException(status_code=400, detail="game_id is required for wild_card")
|
||||
effect = await consumables_service.use_wild_card(
|
||||
db, current_user, participant, marathon, assignment, data.game_id
|
||||
)
|
||||
effect_description = f"New challenge from {effect['game_name']}: {effect['challenge_title']}"
|
||||
elif data.item_code == "lucky_dice":
|
||||
effect = await consumables_service.use_lucky_dice(db, current_user, participant, marathon)
|
||||
effect_description = f"Lucky Dice rolled: x{effect['multiplier']} multiplier"
|
||||
elif data.item_code == "copycat":
|
||||
if data.target_participant_id is None:
|
||||
raise HTTPException(status_code=400, detail="target_participant_id is required for copycat")
|
||||
effect = await consumables_service.use_copycat(
|
||||
db, current_user, participant, marathon, assignment, data.target_participant_id
|
||||
)
|
||||
effect_description = f"Copied challenge: {effect['challenge_title']}"
|
||||
elif data.item_code == "undo":
|
||||
effect = await consumables_service.use_undo(db, current_user, participant, marathon)
|
||||
effect_description = f"Restored {effect['points_restored']} points and streak {effect['streak_restored']}"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown consumable: {data.item_code}")
|
||||
|
||||
@@ -243,9 +268,11 @@ async def get_consumables_status(
|
||||
|
||||
# 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")
|
||||
wild_cards_available = await consumables_service.get_consumable_count(db, current_user.id, "wild_card")
|
||||
lucky_dice_available = await consumables_service.get_consumable_count(db, current_user.id, "lucky_dice")
|
||||
copycats_available = await consumables_service.get_consumable_count(db, current_user.id, "copycat")
|
||||
undos_available = await consumables_service.get_consumable_count(db, current_user.id, "undo")
|
||||
|
||||
# Calculate remaining skips for this marathon
|
||||
skips_remaining = None
|
||||
@@ -256,12 +283,16 @@ 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=consumables_service.BOOST_MULTIPLIER if participant.has_active_boost else None,
|
||||
rerolls_available=rerolls_available,
|
||||
wild_cards_available=wild_cards_available,
|
||||
lucky_dice_available=lucky_dice_available,
|
||||
has_lucky_dice=participant.has_lucky_dice,
|
||||
lucky_dice_multiplier=participant.lucky_dice_multiplier,
|
||||
copycats_available=copycats_available,
|
||||
undos_available=undos_available,
|
||||
can_undo=participant.can_undo,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -621,10 +621,12 @@ async def complete_assignment(
|
||||
if ba.status == BonusAssignmentStatus.COMPLETED.value:
|
||||
ba.points_earned = int(ba.challenge.points * multiplier)
|
||||
|
||||
# Apply boost multiplier from consumable
|
||||
# Apply boost and lucky dice multipliers from consumables
|
||||
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
|
||||
if boost_multiplier > 1.0:
|
||||
total_points = int(total_points * boost_multiplier)
|
||||
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
|
||||
combined_multiplier = boost_multiplier * lucky_dice_multiplier
|
||||
if combined_multiplier != 1.0:
|
||||
total_points = int(total_points * combined_multiplier)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.COMPLETED.value
|
||||
@@ -666,6 +668,8 @@ async def complete_assignment(
|
||||
activity_data["is_redo"] = True
|
||||
if boost_multiplier > 1.0:
|
||||
activity_data["boost_multiplier"] = boost_multiplier
|
||||
if lucky_dice_multiplier != 1.0:
|
||||
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
|
||||
if coins_earned > 0:
|
||||
activity_data["coins_earned"] = coins_earned
|
||||
if playthrough_event:
|
||||
@@ -728,10 +732,12 @@ async def complete_assignment(
|
||||
total_points += common_enemy_bonus
|
||||
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
|
||||
|
||||
# Apply boost multiplier from consumable
|
||||
# Apply boost and lucky dice multipliers from consumables
|
||||
boost_multiplier = consumables_service.consume_boost_on_complete(participant)
|
||||
if boost_multiplier > 1.0:
|
||||
total_points = int(total_points * boost_multiplier)
|
||||
lucky_dice_multiplier = consumables_service.consume_lucky_dice_on_complete(participant)
|
||||
combined_multiplier = boost_multiplier * lucky_dice_multiplier
|
||||
if combined_multiplier != 1.0:
|
||||
total_points = int(total_points * combined_multiplier)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.COMPLETED.value
|
||||
@@ -772,6 +778,8 @@ async def complete_assignment(
|
||||
activity_data["is_redo"] = True
|
||||
if boost_multiplier > 1.0:
|
||||
activity_data["boost_multiplier"] = boost_multiplier
|
||||
if lucky_dice_multiplier != 1.0:
|
||||
activity_data["lucky_dice_multiplier"] = lucky_dice_multiplier
|
||||
if coins_earned > 0:
|
||||
activity_data["coins_earned"] = coins_earned
|
||||
if assignment.event_type == EventType.JACKPOT.value:
|
||||
@@ -887,11 +895,10 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
participant.drop_count, game.playthrough_points, playthrough_event
|
||||
)
|
||||
|
||||
# Check for shield - if active, no penalty
|
||||
shield_used = False
|
||||
if consumables_service.consume_shield(participant):
|
||||
penalty = 0
|
||||
shield_used = True
|
||||
# Save drop data for potential undo
|
||||
consumables_service.save_drop_for_undo(
|
||||
participant, penalty, participant.current_streak
|
||||
)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
@@ -921,8 +928,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
"penalty": penalty,
|
||||
"lost_bonuses": completed_bonuses_count,
|
||||
}
|
||||
if shield_used:
|
||||
activity_data["shield_used"] = True
|
||||
if playthrough_event:
|
||||
activity_data["event_type"] = playthrough_event.type
|
||||
activity_data["free_drop"] = True
|
||||
@@ -941,7 +946,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
penalty=penalty,
|
||||
total_points=participant.total_points,
|
||||
new_drop_count=participant.drop_count,
|
||||
shield_used=shield_used,
|
||||
)
|
||||
|
||||
# Regular challenge drop
|
||||
@@ -953,11 +957,10 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
# Calculate penalty (0 if double_risk event is active)
|
||||
penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
|
||||
|
||||
# Check for shield - if active, no penalty
|
||||
shield_used = False
|
||||
if consumables_service.consume_shield(participant):
|
||||
penalty = 0
|
||||
shield_used = True
|
||||
# Save drop data for potential undo
|
||||
consumables_service.save_drop_for_undo(
|
||||
participant, penalty, participant.current_streak
|
||||
)
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
@@ -975,8 +978,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
"difficulty": assignment.challenge.difficulty,
|
||||
"penalty": penalty,
|
||||
}
|
||||
if shield_used:
|
||||
activity_data["shield_used"] = True
|
||||
if active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
if active_event.type == EventType.DOUBLE_RISK.value:
|
||||
@@ -996,7 +997,6 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
penalty=penalty,
|
||||
total_points=participant.total_points,
|
||||
new_drop_count=participant.drop_count,
|
||||
shield_used=shield_used,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, Boolean, Float
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -32,7 +32,15 @@ class Participant(Base):
|
||||
# Shop: consumables state
|
||||
skips_used: Mapped[int] = mapped_column(Integer, default=0)
|
||||
has_active_boost: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
has_shield: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Lucky Dice state
|
||||
has_lucky_dice: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
lucky_dice_multiplier: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
# Undo state - stores last drop data for potential rollback
|
||||
last_drop_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
last_drop_streak_before: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
can_undo: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", back_populates="participations")
|
||||
|
||||
@@ -28,9 +28,11 @@ class ItemRarity(str, Enum):
|
||||
|
||||
class ConsumableType(str, Enum):
|
||||
SKIP = "skip"
|
||||
SHIELD = "shield"
|
||||
BOOST = "boost"
|
||||
REROLL = "reroll"
|
||||
WILD_CARD = "wild_card"
|
||||
LUCKY_DICE = "lucky_dice"
|
||||
COPYCAT = "copycat"
|
||||
UNDO = "undo"
|
||||
|
||||
|
||||
class ShopItem(Base):
|
||||
|
||||
@@ -86,7 +86,6 @@ class DropResult(BaseModel):
|
||||
penalty: int
|
||||
total_points: int
|
||||
new_drop_count: int
|
||||
shield_used: bool = False # Whether shield consumable was used to prevent penalty
|
||||
|
||||
|
||||
class EventAssignmentResponse(BaseModel):
|
||||
|
||||
@@ -43,10 +43,10 @@ class ParticipantInfo(BaseModel):
|
||||
# Shop: coins and consumables status
|
||||
coins_earned: int = 0
|
||||
skips_used: int = 0
|
||||
has_shield: bool = False
|
||||
has_active_boost: bool = False
|
||||
boost_multiplier: float | None = None
|
||||
boost_expires_at: datetime | None = None
|
||||
has_lucky_dice: bool = False
|
||||
lucky_dice_multiplier: float | None = None
|
||||
can_undo: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -94,9 +94,11 @@ class PurchaseResponse(BaseModel):
|
||||
|
||||
class UseConsumableRequest(BaseModel):
|
||||
"""Schema for using a consumable"""
|
||||
item_code: str # 'skip', 'shield', 'boost', 'reroll'
|
||||
item_code: str # 'skip', 'boost', 'wild_card', 'lucky_dice', 'copycat', 'undo'
|
||||
marathon_id: int
|
||||
assignment_id: int | None = None # Required for skip and reroll
|
||||
assignment_id: int | None = None # Required for skip, wild_card, copycat
|
||||
game_id: int | None = None # Required for wild_card
|
||||
target_participant_id: int | None = None # Required for copycat
|
||||
|
||||
|
||||
class UseConsumableResponse(BaseModel):
|
||||
@@ -192,9 +194,13 @@ class ConsumablesStatusResponse(BaseModel):
|
||||
skips_available: int # From inventory
|
||||
skips_used: int # In this marathon
|
||||
skips_remaining: int | None # Based on marathon limit
|
||||
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)
|
||||
has_active_boost: bool # Currently activated (one-time for current assignment)
|
||||
boost_multiplier: float | None # 1.5 if boost active
|
||||
rerolls_available: int # From inventory
|
||||
wild_cards_available: int # From inventory
|
||||
lucky_dice_available: int # From inventory
|
||||
has_lucky_dice: bool # Currently activated
|
||||
lucky_dice_multiplier: float | None # Rolled multiplier if active
|
||||
copycats_available: int # From inventory
|
||||
undos_available: int # From inventory
|
||||
can_undo: bool # Has drop data to undo
|
||||
|
||||
@@ -14,19 +14,19 @@ class CoinsService:
|
||||
|
||||
# Coins awarded per challenge difficulty (only in certified marathons)
|
||||
CHALLENGE_COINS = {
|
||||
Difficulty.EASY.value: 5,
|
||||
Difficulty.MEDIUM.value: 12,
|
||||
Difficulty.HARD.value: 25,
|
||||
Difficulty.EASY.value: 10,
|
||||
Difficulty.MEDIUM.value: 20,
|
||||
Difficulty.HARD.value: 35,
|
||||
}
|
||||
|
||||
# Coins for playthrough = points * this ratio
|
||||
PLAYTHROUGH_COIN_RATIO = 0.05 # 5% of points
|
||||
PLAYTHROUGH_COIN_RATIO = 0.10 # 10% of points
|
||||
|
||||
# Coins awarded for marathon placements
|
||||
MARATHON_PLACE_COINS = {
|
||||
1: 100, # 1st place
|
||||
2: 50, # 2nd place
|
||||
3: 30, # 3rd place
|
||||
1: 500, # 1st place
|
||||
2: 250, # 2nd place
|
||||
3: 150, # 3rd place
|
||||
}
|
||||
|
||||
# Bonus coins for Common Enemy event winners
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
"""
|
||||
Consumables Service - handles consumable items usage (skip, shield, boost, reroll)
|
||||
Consumables Service - handles consumable items usage
|
||||
|
||||
Consumables:
|
||||
- skip: Skip current assignment without penalty
|
||||
- boost: x1.5 multiplier for current assignment
|
||||
- wild_card: Choose a game, get random challenge from it
|
||||
- lucky_dice: Random multiplier (0.5, 1.0, 1.5, 2.0, 2.5, 3.0)
|
||||
- copycat: Copy another participant's assignment
|
||||
- undo: Restore points and streak from last drop
|
||||
"""
|
||||
import random
|
||||
from datetime import datetime
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import (
|
||||
User, Participant, Marathon, Assignment, AssignmentStatus,
|
||||
ShopItem, UserInventory, ConsumableUsage, ConsumableType
|
||||
ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge,
|
||||
BonusAssignment
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +29,9 @@ class ConsumablesService:
|
||||
# Boost settings
|
||||
BOOST_MULTIPLIER = 1.5
|
||||
|
||||
# Lucky Dice multipliers (equal probability, starts from 1.5x)
|
||||
LUCKY_DICE_MULTIPLIERS = [1.5, 2.0, 2.5, 3.0, 3.5, 4.0]
|
||||
|
||||
async def use_skip(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -85,53 +98,6 @@ class ConsumablesService:
|
||||
"streak_preserved": True,
|
||||
}
|
||||
|
||||
async def use_shield(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Activate a Shield - protects from next drop penalty.
|
||||
|
||||
- Next drop will not cause point penalty
|
||||
- Streak is preserved on next drop
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or shield already active
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if participant.has_shield:
|
||||
raise HTTPException(status_code=400, detail="Shield is already active")
|
||||
|
||||
# Consume shield from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.SHIELD.value)
|
||||
|
||||
# Activate shield
|
||||
participant.has_shield = True
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "shield",
|
||||
"activated": True,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"shield_activated": True,
|
||||
}
|
||||
|
||||
async def use_boost(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -140,10 +106,10 @@ class ConsumablesService:
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Activate a Boost - multiplies points for NEXT complete only.
|
||||
Activate a Boost - multiplies points for current assignment on complete.
|
||||
|
||||
- Points for next completed challenge are multiplied by BOOST_MULTIPLIER
|
||||
- One-time use (consumed on next complete)
|
||||
- Points for completed challenge are multiplied by BOOST_MULTIPLIER
|
||||
- One-time use (consumed on complete)
|
||||
|
||||
Returns: dict with result info
|
||||
|
||||
@@ -181,41 +147,71 @@ class ConsumablesService:
|
||||
"multiplier": self.BOOST_MULTIPLIER,
|
||||
}
|
||||
|
||||
async def use_reroll(
|
||||
async def use_wild_card(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
game_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Use a Reroll - discard current assignment and spin again.
|
||||
Use Wild Card - choose a game and get a random challenge from it.
|
||||
|
||||
- Current assignment is cancelled (not dropped)
|
||||
- User can spin the wheel again
|
||||
- No penalty
|
||||
- Current assignment is replaced
|
||||
- New challenge is randomly selected from the chosen game
|
||||
- Game must be in the marathon
|
||||
|
||||
Returns: dict with result info
|
||||
Returns: dict with new assignment info
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or assignment not active
|
||||
HTTPException: If game not in marathon or no challenges available
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Can only reroll active assignments")
|
||||
raise HTTPException(status_code=400, detail="Can only use wild card on active assignments")
|
||||
|
||||
# Consume reroll from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.REROLL.value)
|
||||
# Verify game is in this marathon
|
||||
result = await db.execute(
|
||||
select(Game)
|
||||
.where(
|
||||
Game.id == game_id,
|
||||
Game.marathon_id == marathon.id,
|
||||
)
|
||||
)
|
||||
game = result.scalar_one_or_none()
|
||||
|
||||
# Cancel current assignment
|
||||
old_challenge_id = assignment.challenge_id
|
||||
if not game:
|
||||
raise HTTPException(status_code=400, detail="Game not found in this marathon")
|
||||
|
||||
# Get random challenge from this game
|
||||
result = await db.execute(
|
||||
select(Challenge)
|
||||
.where(Challenge.game_id == game_id)
|
||||
.order_by(func.random())
|
||||
.limit(1)
|
||||
)
|
||||
new_challenge = result.scalar_one_or_none()
|
||||
|
||||
if not new_challenge:
|
||||
raise HTTPException(status_code=400, detail="No challenges available for this game")
|
||||
|
||||
# Consume wild card from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.WILD_CARD.value)
|
||||
|
||||
# Store old assignment info for logging
|
||||
old_game_id = assignment.game_id
|
||||
assignment.status = AssignmentStatus.DROPPED.value
|
||||
assignment.completed_at = datetime.utcnow()
|
||||
# Note: We do NOT increase drop_count (this is a reroll, not a real drop)
|
||||
old_challenge_id = assignment.challenge_id
|
||||
|
||||
# Update assignment with new challenge
|
||||
assignment.game_id = game_id
|
||||
assignment.challenge_id = new_challenge.id
|
||||
# Reset timestamps since it's a new challenge
|
||||
assignment.started_at = datetime.utcnow()
|
||||
assignment.deadline = None # Will be recalculated if needed
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
@@ -224,17 +220,275 @@ class ConsumablesService:
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "reroll",
|
||||
"rerolled_from_challenge_id": old_challenge_id,
|
||||
"rerolled_from_game_id": old_game_id,
|
||||
"type": "wild_card",
|
||||
"old_game_id": old_game_id,
|
||||
"old_challenge_id": old_challenge_id,
|
||||
"new_game_id": game_id,
|
||||
"new_challenge_id": new_challenge.id,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"rerolled": True,
|
||||
"can_spin_again": True,
|
||||
"game_id": game_id,
|
||||
"game_name": game.name,
|
||||
"challenge_id": new_challenge.id,
|
||||
"challenge_title": new_challenge.title,
|
||||
}
|
||||
|
||||
async def use_lucky_dice(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Lucky Dice - get a random multiplier for current assignment.
|
||||
|
||||
- Random multiplier from [0.5, 1.0, 1.5, 2.0, 2.5, 3.0]
|
||||
- Applied on next complete (stacks with boost if both active)
|
||||
- One-time use
|
||||
|
||||
Returns: dict with rolled multiplier
|
||||
|
||||
Raises:
|
||||
HTTPException: If consumables not allowed or lucky dice already active
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if participant.has_lucky_dice:
|
||||
raise HTTPException(status_code=400, detail="Lucky Dice is already active")
|
||||
|
||||
# Consume lucky dice from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.LUCKY_DICE.value)
|
||||
|
||||
# Roll the dice
|
||||
multiplier = random.choice(self.LUCKY_DICE_MULTIPLIERS)
|
||||
|
||||
# Activate lucky dice
|
||||
participant.has_lucky_dice = True
|
||||
participant.lucky_dice_multiplier = multiplier
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "lucky_dice",
|
||||
"multiplier": multiplier,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"lucky_dice_activated": True,
|
||||
"multiplier": multiplier,
|
||||
}
|
||||
|
||||
async def use_copycat(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
assignment: Assignment,
|
||||
target_participant_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Copycat - copy another participant's assignment.
|
||||
|
||||
- Current assignment is replaced with target's current/last assignment
|
||||
- Can copy even if target already completed theirs
|
||||
- Cannot copy your own assignment
|
||||
|
||||
Returns: dict with copied assignment info
|
||||
|
||||
Raises:
|
||||
HTTPException: If target not found or no assignment to copy
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if assignment.status != AssignmentStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="Can only use copycat on active assignments")
|
||||
|
||||
if target_participant_id == participant.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot copy your own assignment")
|
||||
|
||||
# Find target participant
|
||||
result = await db.execute(
|
||||
select(Participant)
|
||||
.where(
|
||||
Participant.id == target_participant_id,
|
||||
Participant.marathon_id == marathon.id,
|
||||
)
|
||||
)
|
||||
target_participant = result.scalar_one_or_none()
|
||||
|
||||
if not target_participant:
|
||||
raise HTTPException(status_code=400, detail="Target participant not found")
|
||||
|
||||
# Get target's most recent assignment (active or completed)
|
||||
result = await db.execute(
|
||||
select(Assignment)
|
||||
.options(
|
||||
selectinload(Assignment.challenge),
|
||||
selectinload(Assignment.game).selectinload(Game.challenges),
|
||||
)
|
||||
.where(
|
||||
Assignment.participant_id == target_participant_id,
|
||||
Assignment.status.in_([
|
||||
AssignmentStatus.ACTIVE.value,
|
||||
AssignmentStatus.COMPLETED.value
|
||||
])
|
||||
)
|
||||
.order_by(Assignment.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
target_assignment = result.scalar_one_or_none()
|
||||
|
||||
if not target_assignment:
|
||||
raise HTTPException(status_code=400, detail="Target has no assignment to copy")
|
||||
|
||||
# Consume copycat from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.COPYCAT.value)
|
||||
|
||||
# Store old assignment info for logging
|
||||
old_game_id = assignment.game_id
|
||||
old_challenge_id = assignment.challenge_id
|
||||
old_is_playthrough = assignment.is_playthrough
|
||||
|
||||
# Copy the assignment - handle both challenge and playthrough
|
||||
assignment.game_id = target_assignment.game_id
|
||||
assignment.challenge_id = target_assignment.challenge_id
|
||||
assignment.is_playthrough = target_assignment.is_playthrough
|
||||
# Reset timestamps
|
||||
assignment.started_at = datetime.utcnow()
|
||||
assignment.deadline = None
|
||||
|
||||
# If copying a playthrough, recreate bonus assignments
|
||||
if target_assignment.is_playthrough:
|
||||
# Delete existing bonus assignments
|
||||
for ba in assignment.bonus_assignments:
|
||||
await db.delete(ba)
|
||||
|
||||
# Create new bonus assignments from target game's challenges
|
||||
if target_assignment.game and target_assignment.game.challenges:
|
||||
for ch in target_assignment.game.challenges:
|
||||
bonus = BonusAssignment(
|
||||
main_assignment_id=assignment.id,
|
||||
challenge_id=ch.id,
|
||||
)
|
||||
db.add(bonus)
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
assignment_id=assignment.id,
|
||||
effect_data={
|
||||
"type": "copycat",
|
||||
"old_challenge_id": old_challenge_id,
|
||||
"old_game_id": old_game_id,
|
||||
"old_is_playthrough": old_is_playthrough,
|
||||
"copied_from_participant_id": target_participant_id,
|
||||
"new_challenge_id": target_assignment.challenge_id,
|
||||
"new_game_id": target_assignment.game_id,
|
||||
"new_is_playthrough": target_assignment.is_playthrough,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
# Prepare response
|
||||
if target_assignment.is_playthrough:
|
||||
title = f"Прохождение: {target_assignment.game.title}" if target_assignment.game else "Прохождение"
|
||||
else:
|
||||
title = target_assignment.challenge.title if target_assignment.challenge else None
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"copied": True,
|
||||
"game_id": target_assignment.game_id,
|
||||
"challenge_id": target_assignment.challenge_id,
|
||||
"is_playthrough": target_assignment.is_playthrough,
|
||||
"challenge_title": title,
|
||||
}
|
||||
|
||||
async def use_undo(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
participant: Participant,
|
||||
marathon: Marathon,
|
||||
) -> dict:
|
||||
"""
|
||||
Use Undo - restore points and streak from last drop.
|
||||
|
||||
- Only works if there was a drop in this marathon
|
||||
- Can only undo once per drop
|
||||
- Restores both points and streak
|
||||
|
||||
Returns: dict with restored values
|
||||
|
||||
Raises:
|
||||
HTTPException: If no drop to undo
|
||||
"""
|
||||
if not marathon.allow_consumables:
|
||||
raise HTTPException(status_code=400, detail="Consumables are not allowed in this marathon")
|
||||
|
||||
if not participant.can_undo:
|
||||
raise HTTPException(status_code=400, detail="No drop to undo")
|
||||
|
||||
if participant.last_drop_points is None or participant.last_drop_streak_before is None:
|
||||
raise HTTPException(status_code=400, detail="No drop data to restore")
|
||||
|
||||
# Consume undo from inventory
|
||||
item = await self._consume_item(db, user, ConsumableType.UNDO.value)
|
||||
|
||||
# Store values for logging
|
||||
points_restored = participant.last_drop_points
|
||||
streak_restored = participant.last_drop_streak_before
|
||||
current_points = participant.total_points
|
||||
current_streak = participant.current_streak
|
||||
|
||||
# Restore points and streak
|
||||
participant.total_points += points_restored
|
||||
participant.current_streak = streak_restored
|
||||
participant.drop_count = max(0, participant.drop_count - 1)
|
||||
|
||||
# Clear undo data
|
||||
participant.can_undo = False
|
||||
participant.last_drop_points = None
|
||||
participant.last_drop_streak_before = None
|
||||
|
||||
# Log usage
|
||||
usage = ConsumableUsage(
|
||||
user_id=user.id,
|
||||
item_id=item.id,
|
||||
marathon_id=marathon.id,
|
||||
effect_data={
|
||||
"type": "undo",
|
||||
"points_restored": points_restored,
|
||||
"streak_restored_to": streak_restored,
|
||||
"points_before": current_points,
|
||||
"streak_before": current_streak,
|
||||
},
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"undone": True,
|
||||
"points_restored": points_restored,
|
||||
"streak_restored": streak_restored,
|
||||
"new_total_points": participant.total_points,
|
||||
"new_streak": participant.current_streak,
|
||||
}
|
||||
|
||||
async def _consume_item(
|
||||
@@ -292,17 +546,6 @@ class ConsumablesService:
|
||||
quantity = result.scalar_one_or_none()
|
||||
return quantity or 0
|
||||
|
||||
def consume_shield(self, participant: Participant) -> bool:
|
||||
"""
|
||||
Consume shield when dropping (called from wheel.py).
|
||||
|
||||
Returns: True if shield was consumed, False otherwise
|
||||
"""
|
||||
if participant.has_shield:
|
||||
participant.has_shield = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def consume_boost_on_complete(self, participant: Participant) -> float:
|
||||
"""
|
||||
Consume boost when completing assignment (called from wheel.py).
|
||||
@@ -315,6 +558,33 @@ class ConsumablesService:
|
||||
return self.BOOST_MULTIPLIER
|
||||
return 1.0
|
||||
|
||||
def consume_lucky_dice_on_complete(self, participant: Participant) -> float:
|
||||
"""
|
||||
Consume lucky dice when completing assignment (called from wheel.py).
|
||||
One-time use - consumed after single complete.
|
||||
|
||||
Returns: Multiplier value (rolled multiplier if active, 1.0 otherwise)
|
||||
"""
|
||||
if participant.has_lucky_dice and participant.lucky_dice_multiplier is not None:
|
||||
multiplier = participant.lucky_dice_multiplier
|
||||
participant.has_lucky_dice = False
|
||||
participant.lucky_dice_multiplier = None
|
||||
return multiplier
|
||||
return 1.0
|
||||
|
||||
def save_drop_for_undo(
|
||||
self,
|
||||
participant: Participant,
|
||||
points_lost: int,
|
||||
streak_before: int,
|
||||
) -> None:
|
||||
"""
|
||||
Save drop data for potential undo (called from wheel.py before dropping).
|
||||
"""
|
||||
participant.last_drop_points = points_lost
|
||||
participant.last_drop_streak_before = streak_before
|
||||
participant.can_undo = True
|
||||
|
||||
|
||||
# Singleton instance
|
||||
consumables_service = ConsumablesService()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useToast } from '@/store/toast'
|
||||
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||
import {
|
||||
Loader2, Package, ShoppingBag, Coins, Check,
|
||||
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward
|
||||
Frame, Type, Palette, Image, Zap, SkipForward, Shuffle, Dice5, Copy, Undo2
|
||||
} from 'lucide-react'
|
||||
import type { InventoryItem, ShopItemType } from '@/types'
|
||||
import { RARITY_COLORS, RARITY_NAMES, ITEM_TYPE_NAMES } from '@/types'
|
||||
@@ -13,9 +13,11 @@ import clsx from 'clsx'
|
||||
|
||||
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||
skip: <SkipForward className="w-8 h-8" />,
|
||||
shield: <Shield className="w-8 h-8" />,
|
||||
boost: <Zap className="w-8 h-8" />,
|
||||
reroll: <RefreshCw className="w-8 h-8" />,
|
||||
wild_card: <Shuffle className="w-8 h-8" />,
|
||||
lucky_dice: <Dice5 className="w-8 h-8" />,
|
||||
copycat: <Copy className="w-8 h-8" />,
|
||||
undo: <Undo2 className="w-8 h-8" />,
|
||||
}
|
||||
|
||||
interface InventoryItemCardProps {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
|
||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||
import { SpinWheel } from '@/components/SpinWheel'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, Shield, RefreshCw, SkipForward, Package } from 'lucide-react'
|
||||
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, SkipForward, Package, Dice5, Copy, Undo2, Shuffle } from 'lucide-react'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
import { useShopStore } from '@/store/shop'
|
||||
@@ -494,45 +494,6 @@ export function PlayPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseReroll = async () => {
|
||||
if (!currentAssignment || !id) return
|
||||
setIsUsingConsumable('reroll')
|
||||
try {
|
||||
await shopApi.useConsumable({
|
||||
item_code: 'reroll',
|
||||
marathon_id: parseInt(id),
|
||||
assignment_id: currentAssignment.id,
|
||||
})
|
||||
toast.success('Задание отменено! Можно крутить заново.')
|
||||
await loadData()
|
||||
useShopStore.getState().loadBalance()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось использовать Reroll')
|
||||
} finally {
|
||||
setIsUsingConsumable(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseShield = async () => {
|
||||
if (!id) return
|
||||
setIsUsingConsumable('shield')
|
||||
try {
|
||||
await shopApi.useConsumable({
|
||||
item_code: 'shield',
|
||||
marathon_id: parseInt(id),
|
||||
})
|
||||
toast.success('Shield активирован! Следующий пропуск будет бесплатным.')
|
||||
await loadData()
|
||||
useShopStore.getState().loadBalance()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось активировать Shield')
|
||||
} finally {
|
||||
setIsUsingConsumable(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseBoost = async () => {
|
||||
if (!id) return
|
||||
setIsUsingConsumable('boost')
|
||||
@@ -541,7 +502,7 @@ export function PlayPage() {
|
||||
item_code: 'boost',
|
||||
marathon_id: parseInt(id),
|
||||
})
|
||||
toast.success('Boost активирован! x1.5 очков за следующее выполнение.')
|
||||
toast.success('Boost активирован! x1.5 очков за текущее задание.')
|
||||
await loadData()
|
||||
useShopStore.getState().loadBalance()
|
||||
} catch (err: unknown) {
|
||||
@@ -552,6 +513,119 @@ export function PlayPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Wild Card modal state
|
||||
const [showWildCardModal, setShowWildCardModal] = useState(false)
|
||||
|
||||
const handleUseWildCard = async (gameId: number) => {
|
||||
if (!currentAssignment || !id) return
|
||||
setIsUsingConsumable('wild_card')
|
||||
try {
|
||||
const result = await shopApi.useConsumable({
|
||||
item_code: 'wild_card',
|
||||
marathon_id: parseInt(id),
|
||||
assignment_id: currentAssignment.id,
|
||||
game_id: gameId,
|
||||
})
|
||||
toast.success(result.effect_description)
|
||||
setShowWildCardModal(false)
|
||||
await loadData()
|
||||
useShopStore.getState().loadBalance()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось использовать Wild Card')
|
||||
} finally {
|
||||
setIsUsingConsumable(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseLuckyDice = async () => {
|
||||
if (!id) return
|
||||
setIsUsingConsumable('lucky_dice')
|
||||
try {
|
||||
const result = await shopApi.useConsumable({
|
||||
item_code: 'lucky_dice',
|
||||
marathon_id: parseInt(id),
|
||||
})
|
||||
const multiplier = result.effect_data?.multiplier as number
|
||||
toast.success(`Lucky Dice: x${multiplier} множитель!`)
|
||||
await loadData()
|
||||
useShopStore.getState().loadBalance()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось использовать Lucky Dice')
|
||||
} finally {
|
||||
setIsUsingConsumable(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Copycat modal state
|
||||
const [showCopycatModal, setShowCopycatModal] = useState(false)
|
||||
const [copycatCandidates, setCopycatCandidates] = useState<SwapCandidate[]>([])
|
||||
const [isLoadingCopycatCandidates, setIsLoadingCopycatCandidates] = useState(false)
|
||||
|
||||
const loadCopycatCandidates = async () => {
|
||||
if (!id) return
|
||||
setIsLoadingCopycatCandidates(true)
|
||||
try {
|
||||
const candidates = await eventsApi.getSwapCandidates(parseInt(id))
|
||||
setCopycatCandidates(candidates)
|
||||
} catch (error) {
|
||||
console.error('Failed to load copycat candidates:', error)
|
||||
} finally {
|
||||
setIsLoadingCopycatCandidates(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseCopycat = async (targetParticipantId: number) => {
|
||||
if (!currentAssignment || !id) return
|
||||
setIsUsingConsumable('copycat')
|
||||
try {
|
||||
const result = await shopApi.useConsumable({
|
||||
item_code: 'copycat',
|
||||
marathon_id: parseInt(id),
|
||||
assignment_id: currentAssignment.id,
|
||||
target_participant_id: targetParticipantId,
|
||||
})
|
||||
toast.success(result.effect_description)
|
||||
setShowCopycatModal(false)
|
||||
await loadData()
|
||||
useShopStore.getState().loadBalance()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось использовать Copycat')
|
||||
} finally {
|
||||
setIsUsingConsumable(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseUndo = async () => {
|
||||
if (!id) return
|
||||
const confirmed = await confirm({
|
||||
title: 'Использовать Undo?',
|
||||
message: 'Это вернёт очки и серию от последнего пропуска.',
|
||||
confirmText: 'Использовать',
|
||||
cancelText: 'Отмена',
|
||||
variant: 'info',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
setIsUsingConsumable('undo')
|
||||
try {
|
||||
const result = await shopApi.useConsumable({
|
||||
item_code: 'undo',
|
||||
marathon_id: parseInt(id),
|
||||
})
|
||||
toast.success(result.effect_description)
|
||||
await loadData()
|
||||
useShopStore.getState().loadBalance()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось использовать Undo')
|
||||
} finally {
|
||||
setIsUsingConsumable(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
@@ -710,18 +784,18 @@ export function PlayPage() {
|
||||
</div>
|
||||
|
||||
{/* Active effects */}
|
||||
{(consumablesStatus.has_shield || consumablesStatus.has_active_boost) && (
|
||||
{(consumablesStatus.has_active_boost || consumablesStatus.has_lucky_dice) && (
|
||||
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/30 rounded-xl">
|
||||
<p className="text-green-400 text-sm font-medium mb-2">Активные эффекты:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{consumablesStatus.has_shield && (
|
||||
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg border border-blue-500/30 flex items-center gap-1">
|
||||
<Shield className="w-3 h-3" /> Shield (следующий drop бесплатный)
|
||||
</span>
|
||||
)}
|
||||
{consumablesStatus.has_active_boost && (
|
||||
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg border border-yellow-500/30 flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" /> Boost x1.5 (следующий complete)
|
||||
<Zap className="w-3 h-3" /> Boost x1.5
|
||||
</span>
|
||||
)}
|
||||
{consumablesStatus.has_lucky_dice && (
|
||||
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 text-xs rounded-lg border border-purple-500/30 flex items-center gap-1">
|
||||
<Dice5 className="w-3 h-3" /> Lucky Dice x{consumablesStatus.lucky_dice_multiplier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -752,52 +826,6 @@ export function PlayPage() {
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
{/* Reroll */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-white font-medium">Reroll</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">{consumablesStatus.rerolls_available} шт.</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mb-2">Переспинить задание</p>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleUseReroll}
|
||||
disabled={consumablesStatus.rerolls_available === 0 || !currentAssignment || isUsingConsumable !== null}
|
||||
isLoading={isUsingConsumable === 'reroll'}
|
||||
className="w-full"
|
||||
>
|
||||
Использовать
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
{/* Shield */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-white font-medium">Shield</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">
|
||||
{consumablesStatus.has_shield ? 'Активен' : `${consumablesStatus.shields_available} шт.`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mb-2">Защита от штрафа</p>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleUseShield}
|
||||
disabled={consumablesStatus.has_shield || consumablesStatus.shields_available === 0 || isUsingConsumable !== null}
|
||||
isLoading={isUsingConsumable === 'shield'}
|
||||
className="w-full"
|
||||
>
|
||||
{consumablesStatus.has_shield ? 'Активен' : 'Активировать'}
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
{/* Boost */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -821,10 +849,180 @@ export function PlayPage() {
|
||||
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
{/* Wild Card */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shuffle className="w-4 h-4 text-green-400" />
|
||||
<span className="text-white font-medium">Wild Card</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">{consumablesStatus.wild_cards_available} шт.</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mb-2">Выбрать игру</p>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowWildCardModal(true)}
|
||||
disabled={consumablesStatus.wild_cards_available === 0 || !currentAssignment || isUsingConsumable !== null}
|
||||
isLoading={isUsingConsumable === 'wild_card'}
|
||||
className="w-full"
|
||||
>
|
||||
Выбрать
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
{/* Lucky Dice */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Dice5 className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-white font-medium">Lucky Dice</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">
|
||||
{consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : `${consumablesStatus.lucky_dice_available} шт.`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mb-2">Случайный множитель</p>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleUseLuckyDice}
|
||||
disabled={consumablesStatus.has_lucky_dice || consumablesStatus.lucky_dice_available === 0 || isUsingConsumable !== null}
|
||||
isLoading={isUsingConsumable === 'lucky_dice'}
|
||||
className="w-full"
|
||||
>
|
||||
{consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : 'Бросить'}
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
{/* Copycat */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy className="w-4 h-4 text-cyan-400" />
|
||||
<span className="text-white font-medium">Copycat</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">{consumablesStatus.copycats_available} шт.</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mb-2">Скопировать задание</p>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCopycatModal(true)
|
||||
loadCopycatCandidates()
|
||||
}}
|
||||
disabled={consumablesStatus.copycats_available === 0 || !currentAssignment || isUsingConsumable !== null}
|
||||
isLoading={isUsingConsumable === 'copycat'}
|
||||
className="w-full"
|
||||
>
|
||||
Выбрать
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
{/* Undo */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Undo2 className="w-4 h-4 text-red-400" />
|
||||
<span className="text-white font-medium">Undo</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">{consumablesStatus.undos_available} шт.</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mb-2">Отменить дроп</p>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleUseUndo}
|
||||
disabled={!consumablesStatus.can_undo || consumablesStatus.undos_available === 0 || isUsingConsumable !== null}
|
||||
isLoading={isUsingConsumable === 'undo'}
|
||||
className="w-full"
|
||||
>
|
||||
{consumablesStatus.can_undo ? 'Отменить' : 'Нет дропа'}
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Wild Card Modal */}
|
||||
{showWildCardModal && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<GlassCard className="w-full max-w-md max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-white">Выберите игру</h3>
|
||||
<button
|
||||
onClick={() => setShowWildCardModal(false)}
|
||||
className="p-2 text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Вы получите случайное задание из выбранной игры
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{games.map((game) => (
|
||||
<button
|
||||
key={game.id}
|
||||
onClick={() => handleUseWildCard(game.id)}
|
||||
disabled={isUsingConsumable === 'wild_card'}
|
||||
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-green-500/30 disabled:opacity-50"
|
||||
>
|
||||
<p className="text-white font-medium">{game.title}</p>
|
||||
<p className="text-gray-400 text-xs">{game.challenges_count} челленджей</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copycat Modal */}
|
||||
{showCopycatModal && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<GlassCard className="w-full max-w-md max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-white">Скопировать задание</h3>
|
||||
<button
|
||||
onClick={() => setShowCopycatModal(false)}
|
||||
className="p-2 text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Выберите участника, чьё задание хотите скопировать
|
||||
</p>
|
||||
{isLoadingCopycatCandidates ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
|
||||
</div>
|
||||
) : copycatCandidates.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-8">Нет доступных заданий для копирования</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{copycatCandidates.map((candidate) => (
|
||||
<button
|
||||
key={candidate.participant_id}
|
||||
onClick={() => handleUseCopycat(candidate.participant_id)}
|
||||
disabled={isUsingConsumable === 'copycat'}
|
||||
className="w-full p-3 bg-dark-700/50 hover:bg-dark-700 rounded-xl text-left transition-colors border border-dark-600 hover:border-cyan-500/30 disabled:opacity-50"
|
||||
>
|
||||
<p className="text-white font-medium">{candidate.user.nickname}</p>
|
||||
<p className="text-cyan-400 text-sm">{candidate.challenge_title}</p>
|
||||
<p className="text-gray-500 text-xs">
|
||||
{candidate.game_title} • {candidate.challenge_points} очков • {candidate.challenge_difficulty}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs for Common Enemy event */}
|
||||
{activeEvent?.event?.type === 'common_enemy' && (
|
||||
<div className="flex gap-2 mb-6">
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useConfirm } from '@/store/confirm'
|
||||
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||
import {
|
||||
Loader2, Coins, ShoppingBag, Package, Sparkles,
|
||||
Frame, Type, Palette, Image, Zap, Shield, RefreshCw, SkipForward,
|
||||
Minus, Plus
|
||||
Frame, Type, Palette, Image, Zap, SkipForward,
|
||||
Minus, Plus, Shuffle, Dice5, Copy, Undo2
|
||||
} from 'lucide-react'
|
||||
import type { ShopItem, ShopItemType, ShopItemPublic } from '@/types'
|
||||
import { RARITY_COLORS, RARITY_NAMES } from '@/types'
|
||||
@@ -23,9 +23,11 @@ const ITEM_TYPE_ICONS: Record<ShopItemType, React.ReactNode> = {
|
||||
|
||||
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||
skip: <SkipForward className="w-8 h-8" />,
|
||||
shield: <Shield className="w-8 h-8" />,
|
||||
boost: <Zap className="w-8 h-8" />,
|
||||
reroll: <RefreshCw className="w-8 h-8" />,
|
||||
wild_card: <Shuffle className="w-8 h-8" />,
|
||||
lucky_dice: <Dice5 className="w-8 h-8" />,
|
||||
copycat: <Copy className="w-8 h-8" />,
|
||||
undo: <Undo2 className="w-8 h-8" />,
|
||||
}
|
||||
|
||||
interface ShopItemCardProps {
|
||||
@@ -176,7 +178,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
|
||||
className={clsx(
|
||||
'p-4 border transition-all duration-300',
|
||||
rarityColors.border,
|
||||
item.is_owned && 'opacity-60'
|
||||
item.is_owned && !isConsumable && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{/* Rarity badge */}
|
||||
@@ -196,7 +198,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
|
||||
</p>
|
||||
|
||||
{/* Quantity selector for consumables */}
|
||||
{isConsumable && !item.is_owned && item.is_available && (
|
||||
{isConsumable && item.is_available && (
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<button
|
||||
onClick={decrementQuantity}
|
||||
@@ -236,7 +238,7 @@ function ShopItemCard({ item, onPurchase, isPurchasing }: ShopItemCardProps) {
|
||||
) : (
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => onPurchase(item, quantity)}
|
||||
onClick={() => onPurchase(item, isConsumable ? quantity : 1)}
|
||||
disabled={isPurchasing || !item.is_available}
|
||||
>
|
||||
{isPurchasing ? (
|
||||
@@ -460,9 +462,9 @@ export function ShopPage() {
|
||||
</h3>
|
||||
<ul className="text-gray-400 text-sm space-y-1">
|
||||
<li>• Выполняй задания в <span className="text-neon-400">сертифицированных</span> марафонах</li>
|
||||
<li>• Easy задание — 5 монет, Medium — 12 монет, Hard — 25 монет</li>
|
||||
<li>• Playthrough — ~5% от заработанных очков</li>
|
||||
<li>• Топ-3 места в марафоне: 1-е — 100, 2-е — 50, 3-е — 30 монет</li>
|
||||
<li>• Easy задание — 10 монет, Medium — 20 монет, Hard — 35 монет</li>
|
||||
<li>• Playthrough — ~10% от заработанных очков</li>
|
||||
<li>• Топ-3 места в марафоне: 1-е — 500, 2-е — 250, 3-е — 150 монет</li>
|
||||
</ul>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
@@ -718,7 +718,7 @@ export interface PasswordChangeData {
|
||||
|
||||
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
|
||||
export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||
export type ConsumableType = 'skip' | 'shield' | 'boost' | 'reroll'
|
||||
export type ConsumableType = 'skip' | 'boost' | 'wild_card' | 'lucky_dice' | 'copycat' | 'undo'
|
||||
|
||||
export interface ShopItemPublic {
|
||||
id: number
|
||||
@@ -776,6 +776,8 @@ export interface UseConsumableRequest {
|
||||
item_code: ConsumableType
|
||||
marathon_id: number
|
||||
assignment_id?: number
|
||||
game_id?: number // Required for wild_card
|
||||
target_participant_id?: number // Required for copycat
|
||||
}
|
||||
|
||||
export interface UseConsumableResponse {
|
||||
@@ -805,12 +807,16 @@ export interface ConsumablesStatus {
|
||||
skips_available: number
|
||||
skips_used: number
|
||||
skips_remaining: number | null
|
||||
shields_available: number
|
||||
has_shield: boolean
|
||||
boosts_available: number
|
||||
has_active_boost: boolean
|
||||
boost_multiplier: number | null
|
||||
rerolls_available: number
|
||||
wild_cards_available: number
|
||||
lucky_dice_available: number
|
||||
has_lucky_dice: boolean
|
||||
lucky_dice_multiplier: number | null
|
||||
copycats_available: number
|
||||
undos_available: number
|
||||
can_undo: boolean
|
||||
}
|
||||
|
||||
export interface UserCosmetics {
|
||||
|
||||
Reference in New Issue
Block a user