From 1751c4dd4c6f4e7345ef567e0793f07a1cd87708 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Thu, 8 Jan 2026 08:49:51 +0700 Subject: [PATCH] rework shop --- .../alembic/versions/024_seed_shop_items.py | 4 +- .../versions/026_update_boost_description.py | 46 ++ .../versions/027_consumables_redesign.py | 83 ++++ backend/app/api/v1/shop.py | 71 ++- backend/app/api/v1/wheel.py | 44 +- backend/app/models/participant.py | 12 +- backend/app/models/shop.py | 6 +- backend/app/schemas/assignment.py | 1 - backend/app/schemas/marathon.py | 6 +- backend/app/schemas/shop.py | 18 +- backend/app/services/coins.py | 14 +- backend/app/services/consumables.py | 438 ++++++++++++++---- frontend/src/pages/InventoryPage.tsx | 8 +- frontend/src/pages/PlayPage.tsx | 386 +++++++++++---- frontend/src/pages/ShopPage.tsx | 22 +- frontend/src/types/index.ts | 14 +- 16 files changed, 913 insertions(+), 260 deletions(-) create mode 100644 backend/alembic/versions/026_update_boost_description.py create mode 100644 backend/alembic/versions/027_consumables_redesign.py diff --git a/backend/alembic/versions/024_seed_shop_items.py b/backend/alembic/versions/024_seed_shop_items.py index 2fd8046..7f8a498 100644 --- a/backend/alembic/versions/024_seed_shop_items.py +++ b/backend/alembic/versions/024_seed_shop_items.py @@ -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, diff --git a/backend/alembic/versions/026_update_boost_description.py b/backend/alembic/versions/026_update_boost_description.py new file mode 100644 index 0000000..3922dee --- /dev/null +++ b/backend/alembic/versions/026_update_boost_description.py @@ -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' + """) diff --git a/backend/alembic/versions/027_consumables_redesign.py b/backend/alembic/versions/027_consumables_redesign.py new file mode 100644 index 0000000..808cbbb --- /dev/null +++ b/backend/alembic/versions/027_consumables_redesign.py @@ -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}') + """) diff --git a/backend/app/api/v1/shop.py b/backend/app/api/v1/shop.py index 3e3706e..f765d90 100644 --- a/backend/app/api/v1/shop.py +++ b/backend/app/api/v1/shop.py @@ -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, ) diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index 2f3177d..b8e2b18 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -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, ) diff --git a/backend/app/models/participant.py b/backend/app/models/participant.py index c869e92..d8af655 100644 --- a/backend/app/models/participant.py +++ b/backend/app/models/participant.py @@ -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") diff --git a/backend/app/models/shop.py b/backend/app/models/shop.py index 31bde3a..d83fba3 100644 --- a/backend/app/models/shop.py +++ b/backend/app/models/shop.py @@ -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): diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py index 5cad6e9..e80ec84 100644 --- a/backend/app/schemas/assignment.py +++ b/backend/app/schemas/assignment.py @@ -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): diff --git a/backend/app/schemas/marathon.py b/backend/app/schemas/marathon.py index fcd8c15..b32dea3 100644 --- a/backend/app/schemas/marathon.py +++ b/backend/app/schemas/marathon.py @@ -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 diff --git a/backend/app/schemas/shop.py b/backend/app/schemas/shop.py index a76c9b9..cbf757d 100644 --- a/backend/app/schemas/shop.py +++ b/backend/app/schemas/shop.py @@ -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 diff --git a/backend/app/services/coins.py b/backend/app/services/coins.py index aacae0b..5e31aa4 100644 --- a/backend/app/services/coins.py +++ b/backend/app/services/coins.py @@ -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 diff --git a/backend/app/services/consumables.py b/backend/app/services/consumables.py index f0273ed..05ec983 100644 --- a/backend/app/services/consumables.py +++ b/backend/app/services/consumables.py @@ -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() diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 06877ba..2c2bd23 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -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 = { skip: , - shield: , boost: , - reroll: , + wild_card: , + lucky_dice: , + copycat: , + undo: , } interface InventoryItemCardProps { diff --git a/frontend/src/pages/PlayPage.tsx b/frontend/src/pages/PlayPage.tsx index 7fcd393..496dfe3 100644 --- a/frontend/src/pages/PlayPage.tsx +++ b/frontend/src/pages/PlayPage.tsx @@ -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([]) + 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 (
@@ -710,18 +784,18 @@ export function PlayPage() {
{/* Active effects */} - {(consumablesStatus.has_shield || consumablesStatus.has_active_boost) && ( + {(consumablesStatus.has_active_boost || consumablesStatus.has_lucky_dice) && (

Активные эффекты:

- {consumablesStatus.has_shield && ( - - Shield (следующий drop бесплатный) - - )} {consumablesStatus.has_active_boost && ( - Boost x1.5 (следующий complete) + Boost x1.5 + + )} + {consumablesStatus.has_lucky_dice && ( + + Lucky Dice x{consumablesStatus.lucky_dice_multiplier} )}
@@ -752,52 +826,6 @@ export function PlayPage() {
- {/* Reroll */} -
-
-
- - Reroll -
- {consumablesStatus.rerolls_available} шт. -
-

Переспинить задание

- - Использовать - -
- - {/* Shield */} -
-
-
- - Shield -
- - {consumablesStatus.has_shield ? 'Активен' : `${consumablesStatus.shields_available} шт.`} - -
-

Защита от штрафа

- - {consumablesStatus.has_shield ? 'Активен' : 'Активировать'} - -
- {/* Boost */}
@@ -821,10 +849,180 @@ export function PlayPage() { {consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
+ + {/* Wild Card */} +
+
+
+ + Wild Card +
+ {consumablesStatus.wild_cards_available} шт. +
+

Выбрать игру

+ setShowWildCardModal(true)} + disabled={consumablesStatus.wild_cards_available === 0 || !currentAssignment || isUsingConsumable !== null} + isLoading={isUsingConsumable === 'wild_card'} + className="w-full" + > + Выбрать + +
+ + {/* Lucky Dice */} +
+
+
+ + Lucky Dice +
+ + {consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : `${consumablesStatus.lucky_dice_available} шт.`} + +
+

Случайный множитель

+ + {consumablesStatus.has_lucky_dice ? `x${consumablesStatus.lucky_dice_multiplier}` : 'Бросить'} + +
+ + {/* Copycat */} +
+
+
+ + Copycat +
+ {consumablesStatus.copycats_available} шт. +
+

Скопировать задание

+ { + setShowCopycatModal(true) + loadCopycatCandidates() + }} + disabled={consumablesStatus.copycats_available === 0 || !currentAssignment || isUsingConsumable !== null} + isLoading={isUsingConsumable === 'copycat'} + className="w-full" + > + Выбрать + +
+ + {/* Undo */} +
+
+
+ + Undo +
+ {consumablesStatus.undos_available} шт. +
+

Отменить дроп

+ + {consumablesStatus.can_undo ? 'Отменить' : 'Нет дропа'} + +
)} + {/* Wild Card Modal */} + {showWildCardModal && ( +
+ +
+

Выберите игру

+ +
+

+ Вы получите случайное задание из выбранной игры +

+
+ {games.map((game) => ( + + ))} +
+
+
+ )} + + {/* Copycat Modal */} + {showCopycatModal && ( +
+ +
+

Скопировать задание

+ +
+

+ Выберите участника, чьё задание хотите скопировать +

+ {isLoadingCopycatCandidates ? ( +
+ +
+ ) : copycatCandidates.length === 0 ? ( +

Нет доступных заданий для копирования

+ ) : ( +
+ {copycatCandidates.map((candidate) => ( + + ))} +
+ )} +
+
+ )} + {/* Tabs for Common Enemy event */} {activeEvent?.event?.type === 'common_enemy' && (
diff --git a/frontend/src/pages/ShopPage.tsx b/frontend/src/pages/ShopPage.tsx index dcafcba..73781b1 100644 --- a/frontend/src/pages/ShopPage.tsx +++ b/frontend/src/pages/ShopPage.tsx @@ -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 = { const CONSUMABLE_ICONS: Record = { skip: , - shield: , boost: , - reroll: , + wild_card: , + lucky_dice: , + copycat: , + undo: , } 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) {

{/* Quantity selector for consumables */} - {isConsumable && !item.is_owned && item.is_available && ( + {isConsumable && item.is_available && (
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 649730f..213b994 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 {