diff --git a/main.py b/main.py index e69de29..83f2742 100644 --- a/main.py +++ b/main.py @@ -0,0 +1,34 @@ +import logging +from src.bot.bot import bootstrap +from src.bot.db.database import init_db, close_db +import asyncio +import sys + +if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('bot_logs.log') + ] +) + +logging.getLogger('tortoise').setLevel(logging.DEBUG) +logging.getLogger('db_client').setLevel(logging.DEBUG) + +async def main() -> None: + await init_db() + try: + await bootstrap() + + except KeyboardInterrupt: + print("Telegram bot was closed!") + + finally: + await close_db() + +if __name__ == '__main__': + asyncio.run(main()) \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..59720ee --- /dev/null +++ b/readme.md @@ -0,0 +1,47 @@ +# CometFall — текстовая MMORPG в Telegram + +![Python](https://img.shields.io/badge/python-3.11-blue) +![Aiogram](https://img.shields.io/badge/aiogram-3.22-green) +![Tortoise ORM](https://img.shields.io/badge/tortoise--orm-0.25-orange) +![PostgreSQL](https://img.shields.io/badge/postgres-15-blue) +![License](https://img.shields.io/badge/license-MIT-brightgreen) + +--- + +**CometFall - это современный проект**, который включает в себя **совершенные подходы программирования** и **структуризации**. + +Бей врагов, прокачивай меч, собирай дроп, покупай оружия, соревнуйся с другими игроками — и всё это в **Telegram** без **никаких лишних действий**. + +[ИГРАТЬ СЕЙЧАС](https://t.me/CometFall_bot)\ +тг: @CometFall_bot + +--- + +## Что умеет бот: + +--- + +## Структура проекта: + +``` +src/ + └─ bot/ + ├─ handlers/ - команды и колбэки + ├─ keyboards/ - кнопки + ├─ services/ - сервисы инвентаря, врагов, дропа + ├─ middlewares/ - проверки, логирование + ├─ game/ + │ ├─ logic/ - расчёт урона, брони + │ ├─ views/ - создание читабельного сообщения + │ └─ config.py - конфигурация + ├─ db/ + │ ├─ models.py - Users, Items, Enemies + │ └─ schemas/ - Pydantic-валидация атрибутов + └─ config.py - Получения констант из .env, mapping текста + +``` + +--- + +Наша команда: **TeraCodeFrame**\ +тг: @TeraCodeFrame \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2a8097f..8089b66 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/scripts/basedb/__init__.py b/scripts/basedb/__init__.py new file mode 100644 index 0000000..bd56cb6 --- /dev/null +++ b/scripts/basedb/__init__.py @@ -0,0 +1,2 @@ +# basedb package + diff --git a/scripts/basedb/constants.py b/scripts/basedb/constants.py new file mode 100644 index 0000000..98a7166 --- /dev/null +++ b/scripts/basedb/constants.py @@ -0,0 +1,57 @@ +TOTAL_WEAPONS = 100 +TOTAL_ARMORS = 100 + +RARITY_ROTATION = ("common", "rare", "epic", "legendary") + +WEAPON_PREFIXES = [ + "Кометный", + "Звёздный", + "Астральный", + "Грозовой", + "Неоновый", + "Ледяной", + "Пылающий", + "Теневой", + "Хрустальный", + "Гравитационный", +] + +WEAPON_SUFFIXES = [ + "Клинок", + "Меч", + "Коса", + "Сабля", + "Копьё", + "Молот", + "Тесак", + "Катана", + "Глефа", + "Шип", +] + +ARMOR_PREFIXES = [ + "Кометный", + "Лунный", + "Солнечный", + "Грозовой", + "Неоновый", + "Ледяной", + "Пылающий", + "Теневой", + "Хрустальный", + "Гравитационный", +] + +ARMOR_SUFFIXES = [ + "Доспех", + "Кираса", + "Латы", + "Панцирь", + "Нагрудник", + "Бронежилет", + "Мантия", + "Плащ", + "Жилет", + "Скафандр", +] + diff --git a/scripts/basedb/generator.py b/scripts/basedb/generator.py new file mode 100644 index 0000000..5807deb --- /dev/null +++ b/scripts/basedb/generator.py @@ -0,0 +1,178 @@ +import sys +from pathlib import Path +from typing import Iterable, Tuple + +sys.path.append(str(Path(__file__).resolve().parents[2])) + +from tortoise import Tortoise + +from src.bot.db.database import close_db, init_db +from src.bot.db.models import ItemRarityEnum, ItemTypeEnum, Items + +from .constants import ( + ARMOR_PREFIXES, + ARMOR_SUFFIXES, + RARITY_ROTATION, + TOTAL_ARMORS, + TOTAL_WEAPONS, + WEAPON_PREFIXES, + WEAPON_SUFFIXES, +) + +RARITY_MAP = { + "common": ItemRarityEnum.COMMON, + "rare": ItemRarityEnum.RARE, + "epic": ItemRarityEnum.EPIC, + "legendary": ItemRarityEnum.LEGENDARY, +} + + +def _cycled_value(values: Iterable[str], index: int) -> str: + sequence = tuple(values) + return sequence[index % len(sequence)] + + +def _rarity_for_index(index: int) -> ItemRarityEnum: + slug = RARITY_ROTATION[index % len(RARITY_ROTATION)] + return RARITY_MAP[slug] + + +async def _reset_sequence(): + """Reset PostgreSQL sequence to match the maximum ID in the items table.""" + # Get the maximum ID using Tortoise ORM + max_item = await Items.all().order_by("-id").first() + max_id = max_item.id if max_item else 0 + # Reset the sequence to the maximum ID (or 1 if table is empty) + next_id = max(max_id, 1) + + # Execute raw SQL to reset the sequence using the underlying connection + conn = Tortoise.get_connection("default") + # Use execute_query for SELECT statements + await conn.execute_query(f"SELECT setval('items_id_seq', {next_id}, true);") + + +async def _ensure_default_items(): + sword = await Items.get_or_none(id=1) + if sword: + print("Уже есть", sword.name, "(id=1)") + else: + sword = await Items.create( + id=1, + name="Деревянный меч", + type=ItemTypeEnum.WEAPON, + rarity=ItemRarityEnum.COMMON, + attributes={ + "item_level": 1, + "min_damage": 5, + "max_damage": 12, + "attack_speed": 1.0, + "critical_chance": 0.05, + "critical_multiplier": 1.5, + }, + description="Простой деревянный меч. Лучше, чем кулаки.", + ) + print("Создан", sword.name, "(id=1)") + + armor = await Items.get_or_none(id=2) + if armor: + print("Уже есть", armor.name, "(id=2)") + else: + armor = await Items.create( + id=2, + name="Тряпичная броня", + type=ItemTypeEnum.ARMOR, + rarity=ItemRarityEnum.COMMON, + attributes={"item_level": 1, "defense": 8, "health_bonus": 20}, + description="Старая одежда. Немного защищает.", + ) + print("Создана", armor.name, "(id=2)") + + # Reset the sequence to prevent ID conflicts + await _reset_sequence() + + +async def _bulk_create_weapons(start_index: int, count: int): + for offset in range(count): + sequence_index = start_index + offset + rarity = _rarity_for_index(sequence_index) + level = sequence_index + 1 + prefix = _cycled_value(WEAPON_PREFIXES, sequence_index) + suffix = _cycled_value(WEAPON_SUFFIXES, sequence_index // len(WEAPON_PREFIXES)) + name = f"{prefix} {suffix} {level}" + + min_damage = 6 + sequence_index * 2 + max_damage = min_damage + 6 + (sequence_index % 5) + attack_speed = round(1.0 + (sequence_index % 7) * 0.05, 2) + crit_chance = round(0.05 + (sequence_index % 4) * 0.03, 2) + crit_mult = round(1.4 + (sequence_index % 3) * 0.2, 2) + + defaults = { + "type": ItemTypeEnum.WEAPON, + "rarity": rarity, + "attributes": { + "item_level": level, + "min_damage": min_damage, + "max_damage": max_damage, + "attack_speed": attack_speed, + "critical_chance": min(crit_chance, 0.65), + "critical_multiplier": min(crit_mult, 3.5), + }, + "description": ( + f"Оружие {rarity.name.lower()} класса, выкованное мастерами Кометопада. " + f"Номер серии {level}." + ), + } + + _, created = await Items.get_or_create(name=name, defaults=defaults) + if created: + print("Добавлено оружие:", name) + + +async def _bulk_create_armors(start_index: int, count: int): + for offset in range(count): + sequence_index = start_index + offset + rarity = _rarity_for_index(sequence_index) + level = sequence_index + 1 + prefix = _cycled_value(ARMOR_PREFIXES, sequence_index) + suffix = _cycled_value(ARMOR_SUFFIXES, sequence_index // len(ARMOR_PREFIXES)) + name = f"{prefix} {suffix} {level}" + + defense = 10 + sequence_index * 3 + health_bonus = 25 + sequence_index * 5 + defaults = { + "type": ItemTypeEnum.ARMOR, + "rarity": rarity, + "attributes": { + "item_level": level, + "defense": defense, + "health_bonus": health_bonus, + }, + "description": ( + f"Броня {rarity.name.lower()} класса, созданная для защитников Кометопада. " + f"Серия {level}." + ), + } + + _, created = await Items.get_or_create(name=name, defaults=defaults) + if created: + print("Добавлена броня:", name) + + +async def create_base_items(): + await init_db() + try: + await _ensure_default_items() + + extra_weapons = TOTAL_WEAPONS - 1 + extra_armors = TOTAL_ARMORS - 1 + + await _bulk_create_weapons(start_index=1, count=extra_weapons) + await _bulk_create_armors(start_index=1, count=extra_armors) + + print("Генерация предметов завершена.") + finally: + await close_db() + + +__all__ = ("create_base_items",) + diff --git a/scripts/gamedb/__init__.py b/scripts/gamedb/__init__.py new file mode 100644 index 0000000..7159955 --- /dev/null +++ b/scripts/gamedb/__init__.py @@ -0,0 +1,2 @@ +# gamedb package + diff --git a/scripts/gamedb/constants.py b/scripts/gamedb/constants.py new file mode 100644 index 0000000..67dff02 --- /dev/null +++ b/scripts/gamedb/constants.py @@ -0,0 +1,211 @@ +# Константы для локаций и врагов + +# Локации +LOCATIONS = [ + { + "name": "Лес Теней", + "description": "Мрачный лес, полный опасностей. Здесь обитают слабые враги, подходящие для новичков.", + "level_required": 1, + }, + { + "name": "Пещеры Глубин", + "description": "Тёмные пещеры, где скрываются более сильные противники. Требуется опыт для выживания.", + "level_required": 5, + }, + { + "name": "Замок Тьмы", + "description": "Древний замок, населённый элитными воинами и могущественными боссами.", + "level_required": 10, + }, + { + "name": "Вершина Дракона", + "description": "Высокогорная крепость, где обитают легендарные существа. Только для опытных воинов.", + "level_required": 15, + }, + { + "name": "Бездна Комет", + "description": "Загадочное измерение, где время и пространство искажены. Самое опасное место во вселенной.", + "level_required": 20, + }, +] + +# Враги (мобы) +# Структура: name, description, type, health_multiplier, damage_multiplier, +# coin_reward_multiplier, exp_reward_multiplier, drop_chance, location_names (список локаций) +ENEMIES = [ + # Лес Теней (уровень 1+) + { + "name": "Теневой Волк", + "description": "Обычный волк, обитающий в тёмном лесу. Быстрый и агрессивный.", + "type": "COMMON", + "health_multiplier": 0.3, + "damage_multiplier": 0.3, + "coin_reward_multiplier": 0.5, + "exp_reward_multiplier": 0.5, + "drop_chance": 20.0, + "location_names": ["Лес Теней"], + }, + { + "name": "Лесной Гоблин", + "description": "Маленький, но хитрый гоблин. Любит нападать из засады.", + "type": "COMMON", + "health_multiplier": 0.4, + "damage_multiplier": 0.35, + "coin_reward_multiplier": 0.6, + "exp_reward_multiplier": 0.6, + "drop_chance": 25.0, + "location_names": ["Лес Теней"], + }, + { + "name": "Вожак Стаи", + "description": "Альфа-волк, возглавляющий стаю. Сильнее обычных волков.", + "type": "ELITE", + "health_multiplier": 0.8, + "damage_multiplier": 0.7, + "coin_reward_multiplier": 1.5, + "exp_reward_multiplier": 1.5, + "drop_chance": 40.0, + "location_names": ["Лес Теней"], + }, + + # Пещеры Глубин (уровень 5+) + { + "name": "Пещерный Паук", + "description": "Огромный паук, плетущий сети в глубинах пещер.", + "type": "COMMON", + "health_multiplier": 0.6, + "damage_multiplier": 0.5, + "coin_reward_multiplier": 0.8, + "exp_reward_multiplier": 0.8, + "drop_chance": 30.0, + "location_names": ["Пещеры Глубин"], + }, + { + "name": "Троглодит-Воин", + "description": "Примитивный воин подземного племени. Силён и вынослив.", + "type": "COMMON", + "health_multiplier": 0.7, + "damage_multiplier": 0.6, + "coin_reward_multiplier": 1.0, + "exp_reward_multiplier": 1.0, + "drop_chance": 35.0, + "location_names": ["Пещеры Глубин"], + }, + { + "name": "Король Троглодитов", + "description": "Могущественный правитель подземного племени. Опасный противник.", + "type": "ELITE", + "health_multiplier": 1.2, + "damage_multiplier": 1.0, + "coin_reward_multiplier": 2.0, + "exp_reward_multiplier": 2.0, + "drop_chance": 50.0, + "location_names": ["Пещеры Глубин"], + }, + + # Замок Тьмы (уровень 10+) + { + "name": "Тёмный Рыцарь", + "description": "Рыцарь, служащий силам тьмы. Обладает мощной броней и оружием.", + "type": "COMMON", + "health_multiplier": 1.0, + "damage_multiplier": 0.9, + "coin_reward_multiplier": 1.5, + "exp_reward_multiplier": 1.5, + "drop_chance": 40.0, + "location_names": ["Замок Тьмы"], + }, + { + "name": "Элитный Страж", + "description": "Элитный воин замка. Обучен лучшими мастерами боевых искусств.", + "type": "ELITE", + "health_multiplier": 1.5, + "damage_multiplier": 1.3, + "coin_reward_multiplier": 2.5, + "exp_reward_multiplier": 2.5, + "drop_chance": 55.0, + "location_names": ["Замок Тьмы"], + }, + { + "name": "Лорд Тьмы", + "description": "Владыка замка. Один из самых могущественных врагов в регионе.", + "type": "BOSS", + "health_multiplier": 3.0, + "damage_multiplier": 2.5, + "coin_reward_multiplier": 5.0, + "exp_reward_multiplier": 5.0, + "drop_chance": 80.0, + "location_names": ["Замок Тьмы"], + }, + + # Вершина Дракона (уровень 15+) + { + "name": "Драконий Охотник", + "description": "Опытный охотник, специализирующийся на драконах. Опасный противник.", + "type": "COMMON", + "health_multiplier": 1.3, + "damage_multiplier": 1.2, + "coin_reward_multiplier": 2.0, + "exp_reward_multiplier": 2.0, + "drop_chance": 45.0, + "location_names": ["Вершина Дракона"], + }, + { + "name": "Драконий Рыцарь", + "description": "Рыцарь, заключивший договор с драконами. Обладает их силой.", + "type": "ELITE", + "health_multiplier": 2.0, + "damage_multiplier": 1.8, + "coin_reward_multiplier": 3.5, + "exp_reward_multiplier": 3.5, + "drop_chance": 60.0, + "location_names": ["Вершина Дракона"], + }, + { + "name": "Древний Дракон", + "description": "Легендарный дракон, живущий тысячи лет. Невероятно могущественное существо.", + "type": "BOSS", + "health_multiplier": 5.0, + "damage_multiplier": 4.0, + "coin_reward_multiplier": 8.0, + "exp_reward_multiplier": 8.0, + "drop_chance": 90.0, + "location_names": ["Вершина Дракона"], + }, + + # Бездна Комет (уровень 20+) + { + "name": "Искажённый Элементаль", + "description": "Существо из искажённого измерения. Его природа непонятна.", + "type": "COMMON", + "health_multiplier": 1.8, + "damage_multiplier": 1.6, + "coin_reward_multiplier": 3.0, + "exp_reward_multiplier": 3.0, + "drop_chance": 50.0, + "location_names": ["Бездна Комет"], + }, + { + "name": "Хранитель Бездны", + "description": "Элитный страж искажённого измерения. Защищает его тайны.", + "type": "ELITE", + "health_multiplier": 2.8, + "damage_multiplier": 2.5, + "coin_reward_multiplier": 5.0, + "exp_reward_multiplier": 5.0, + "drop_chance": 70.0, + "location_names": ["Бездна Комет"], + }, + { + "name": "Повелитель Бездны", + "description": "Верховное существо искажённого измерения. Самое опасное создание во вселенной.", + "type": "BOSS", + "health_multiplier": 8.0, + "damage_multiplier": 6.0, + "coin_reward_multiplier": 12.0, + "exp_reward_multiplier": 12.0, + "drop_chance": 95.0, + "location_names": ["Бездна Комет"], + }, +] + diff --git a/scripts/gamedb/generator.py b/scripts/gamedb/generator.py new file mode 100644 index 0000000..e4f1c7a --- /dev/null +++ b/scripts/gamedb/generator.py @@ -0,0 +1,122 @@ +import sys +from pathlib import Path +from typing import Dict, List + +sys.path.append(str(Path(__file__).resolve().parents[2])) + +from tortoise import Tortoise + +from src.bot.db.database import close_db, init_db +from src.bot.db.models import EnemyTypeEnum, Enemies, Locations + +from .constants import LOCATIONS, ENEMIES + +TYPE_MAP = { + "COMMON": EnemyTypeEnum.COMMON, + "ELITE": EnemyTypeEnum.ELITE, + "BOSS": EnemyTypeEnum.BOSS, +} + + +async def _reset_sequences(): + """Reset PostgreSQL sequences to match the maximum IDs in tables.""" + # Get the maximum IDs using Tortoise ORM + max_location = await Locations.all().order_by("-id").first() + max_enemy = await Enemies.all().order_by("-id").first() + + max_location_id = max_location.id if max_location else 0 + max_enemy_id = max_enemy.id if max_enemy else 0 + + # Reset sequences + conn = Tortoise.get_connection("default") + next_location_id = max(max_location_id, 1) + next_enemy_id = max(max_enemy_id, 1) + + await conn.execute_query(f"SELECT setval('locations_id_seq', {next_location_id}, true);") + await conn.execute_query(f"SELECT setval('enemies_id_seq', {next_enemy_id}, true);") + + +async def create_locations(): + """Create locations from constants.""" + location_map = {} + + for loc_data in LOCATIONS: + location, created = await Locations.get_or_create( + name=loc_data["name"], + defaults={ + "description": loc_data["description"], + "level_required": loc_data["level_required"], + } + ) + + if created: + print(f"Создана локация: {location.name} (id={location.id})") + else: + print(f"Локация уже существует: {location.name} (id={location.id})") + + location_map[location.name] = location + + return location_map + + +async def create_enemies(location_map: Dict[str, Locations]): + """Create enemies from constants and link them to locations.""" + for enemy_data in ENEMIES: + enemy, created = await Enemies.get_or_create( + name=enemy_data["name"], + defaults={ + "description": enemy_data["description"], + "type": TYPE_MAP[enemy_data["type"]], + "health_multiplier": enemy_data["health_multiplier"], + "damage_multiplier": enemy_data["damage_multiplier"], + "coin_reward_multiplier": enemy_data["coin_reward_multiplier"], + "exp_reward_multiplier": enemy_data["exp_reward_multiplier"], + "drop_chance": enemy_data["drop_chance"], + "is_active": True, + } + ) + + if created: + print(f"Создан враг: {enemy.name} (id={enemy.id}, тип={enemy.type.name})") + else: + print(f"Враг уже существует: {enemy.name} (id={enemy.id})") + + # Связываем врага с локациями + for location_name in enemy_data["location_names"]: + if location_name in location_map: + location = location_map[location_name] + # Проверяем, не связан ли уже враг с этой локацией + existing_locations = await enemy.locations.all() + if location not in existing_locations: + await enemy.locations.add(location) + print(f" → Связан с локацией: {location.name}") + else: + print(f" ⚠ Локация '{location_name}' не найдена для врага {enemy.name}") + + +async def create_game_data(): + """Main function to create all game data (locations and enemies).""" + await init_db() + try: + print("=" * 50) + print("Создание локаций...") + print("=" * 50) + location_map = await create_locations() + + print("\n" + "=" * 50) + print("Создание врагов...") + print("=" * 50) + await create_enemies(location_map) + + # Reset sequences after creating items with explicit IDs + await _reset_sequences() + + print("\n" + "=" * 50) + print("Генерация локаций и врагов завершена.") + print("=" * 50) + finally: + await close_db() + + +__all__ = ("create_game_data",) + diff --git a/scripts/init_basedb.py b/scripts/init_basedb.py new file mode 100644 index 0000000..4639420 --- /dev/null +++ b/scripts/init_basedb.py @@ -0,0 +1,16 @@ +# scripts/init_basedb.py +import asyncio +import sys +from pathlib import Path + +# Add scripts directory to path so we can import basedb package +scripts_dir = Path(__file__).resolve().parent +if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + +from basedb.generator import create_base_items + + +if __name__ == "__main__": + asyncio.run(create_base_items()) + diff --git a/scripts/init_cases.py b/scripts/init_cases.py new file mode 100644 index 0000000..9677b64 --- /dev/null +++ b/scripts/init_cases.py @@ -0,0 +1,170 @@ +"""Скрипт для инициализации кейсов в БД. + +Добавляет стартовый набор кейсов с разными коллекциями и редкостью. +Каждый кейс содержит случайные предметы с весами вероятности выпадения. + +Использование: + python scripts/init_cases.py +""" +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from tortoise import Tortoise +from src.bot.db.database import close_db, init_db +from src.bot.db.models import Items, ItemTypeEnum, ItemRarityEnum + +# Данные для кейсов +CASES = [ + { + "name": "Обычный кейс", + "description": "Содержит обычные предметы различных типов", + "rarity": ItemRarityEnum.COMMON, + "type": ItemTypeEnum.CASE, + "attributes": { + "collection": "Базовая", + "is_limited": False, + "max_opens": None, + "storage": [ + # Оружие (ID 1-10) + {"item_id": 1, "weight": 0.3}, + {"item_id": 2, "weight": 0.3}, + # Броня (ID 11-20) + {"item_id": 11, "weight": 0.25}, + {"item_id": 12, "weight": 0.15} + ] + } + }, + { + "name": "Редкий кейс", + "description": "Содержит редкие и ценные предметы", + "rarity": ItemRarityEnum.RARE, + "type": ItemTypeEnum.CASE, + "attributes": { + "collection": "Поиск приключений", + "is_limited": False, + "max_opens": None, + "storage": [ + # Оружие с выше шансом + {"item_id": 3, "weight": 0.4}, + {"item_id": 4, "weight": 0.35}, + # Броня + {"item_id": 13, "weight": 0.25} + ] + } + }, + { + "name": "Эпический кейс", + "description": "Редкий кейс с мощными артефактами", + "rarity": ItemRarityEnum.EPIC, + "type": ItemTypeEnum.CASE, + "attributes": { + "collection": "Легенды войны", + "is_limited": False, + "max_opens": None, + "storage": [ + # Легендарное оружие + {"item_id": 5, "weight": 0.5}, + {"item_id": 6, "weight": 0.5} + ] + } + }, + { + "name": "Легендарный кейс", + "description": "Самый редкий кейс. Гарантированно содержит легендарный предмет", + "rarity": ItemRarityEnum.LEGENDARY, + "type": ItemTypeEnum.CASE, + "attributes": { + "collection": "Сокровища богов", + "is_limited": True, + "max_opens": 100, + "storage": [ + # Только легендарные предметы + {"item_id": 7, "weight": 0.5}, + {"item_id": 8, "weight": 0.5} + ] + } + }, + { + "name": "Рождественский кейс", + "description": "Праздничный кейс с особыми предметами", + "rarity": ItemRarityEnum.EPIC, + "type": ItemTypeEnum.CASE, + "attributes": { + "collection": "Праздники", + "is_limited": True, + "max_opens": 500, + "storage": [ + {"item_id": 9, "weight": 0.6}, + {"item_id": 14, "weight": 0.4} + ] + } + }, + { + "name": "Стартовый набор", + "description": "Идеален для новых игроков. Содержит полезные начальные предметы", + "rarity": ItemRarityEnum.COMMON, + "type": ItemTypeEnum.CASE, + "attributes": { + "collection": "Начинающий", + "is_limited": False, + "max_opens": None, + "storage": [ + {"item_id": 1, "weight": 0.5}, + {"item_id": 11, "weight": 0.5} + ] + } + } +] + + +async def create_cases(): + """Создаёт кейсы в БД или обновляет существующие.""" + created_count = 0 + updated_count = 0 + + for case_data in CASES: + case, created = await Items.get_or_create( + name=case_data["name"], + defaults={ + "description": case_data["description"], + "type": case_data["type"], + "rarity": case_data["rarity"], + "attributes": case_data["attributes"] + } + ) + + if created: + created_count += 1 + print(f"✅ Создан кейс: {case.name} (ID={case.id}, редкость={ItemRarityEnum(case.rarity).name})") + else: + # Обновляем существующий кейс + case.description = case_data["description"] + case.type = case_data["type"] + case.rarity = case_data["rarity"] + case.attributes = case_data["attributes"] + await case.save() + updated_count += 1 + print(f"🔄 Обновлен кейс: {case.name} (ID={case.id})") + + print(f"\n📊 Итого: создано {created_count}, обновлено {updated_count}") + + +async def main(): + """Главная функция для инициализации кейсов.""" + await init_db() + try: + print("🎁 Инициализация кейсов в БД...\n") + await create_cases() + print("\n✅ Кейсы успешно добавлены!") + except Exception as e: + print(f"\n❌ Ошибка при инициализации кейсов: {e}") + raise + finally: + await close_db() + + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) diff --git a/scripts/init_gamedb.py b/scripts/init_gamedb.py new file mode 100644 index 0000000..158536c --- /dev/null +++ b/scripts/init_gamedb.py @@ -0,0 +1,16 @@ +# scripts/init_gamedb.py +import asyncio +import sys +from pathlib import Path + +# Add scripts directory to path so we can import gamedb package +scripts_dir = Path(__file__).resolve().parent +if str(scripts_dir) not in sys.path: + sys.path.insert(0, str(scripts_dir)) + +from gamedb.generator import create_game_data + + +if __name__ == "__main__": + asyncio.run(create_game_data()) + diff --git a/scripts/migrate.py b/scripts/migrate.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bot/bot.py b/src/bot/bot.py new file mode 100644 index 0000000..72bf734 --- /dev/null +++ b/src/bot/bot.py @@ -0,0 +1,40 @@ +from aiogram import Bot, Dispatcher +from aiogram.fsm.storage.memory import MemoryStorage +from .config import ConfigService + +if not ConfigService.BOT_API_KEY: + raise Exception("You should enter `BOT_API_KEY` in .env") + +bot = Bot(token=ConfigService.BOT_API_KEY) + +async def bootstrap() -> None: + from .middlewares.safe_edit import SafePatchMiddleware + from .middlewares.logging import LoggingMiddleware + + from src.bot.handlers.default import default_router + from src.bot.handlers.callback.battle.default import callback_menu_battle + from src.bot.handlers.callback.battle.singleplayer import callback_singleplayer_router + from src.bot.handlers.callback.inventory import callback_inventory_router + from src.bot.handlers.callback.market import callback_market_router + from src.bot.handlers.callback.statistics import callback_statistics_router + from src.bot.handlers.callback.case import case_router + + dp = Dispatcher(storage=MemoryStorage()) + + dp.include_routers( + default_router, + callback_market_router, + callback_menu_battle, + callback_singleplayer_router, + callback_inventory_router, + callback_statistics_router, + case_router, + ) + + dp.message.outer_middleware(SafePatchMiddleware()) + dp.callback_query.outer_middleware(SafePatchMiddleware()) + + dp.message.outer_middleware(LoggingMiddleware()) + dp.callback_query.outer_middleware(LoggingMiddleware()) + + await dp.start_polling(bot) \ No newline at end of file diff --git a/src/bot/config.py b/src/bot/config.py new file mode 100644 index 0000000..c830af4 --- /dev/null +++ b/src/bot/config.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from os import getenv +from dotenv import load_dotenv +from aiogram.types import Message, CallbackQuery + +load_dotenv() + +@dataclass +class ConfigService: + BOT_API_KEY = getenv("BOT_API_KEY") + DATABASE_URL = getenv("DATABASE_URL") + +@dataclass +class TelegramTextMap: + @staticmethod + async def GREETING_TEXT(ctx: Message | CallbackQuery): + return f"Привет, {ctx.from_user.first_name}" + + @staticmethod + async def MAINMENU_TEXT(ctx: Message | CallbackQuery): + return f"{ctx.from_user.first_name}, выберите действие:" + + @staticmethod + async def BATTLE_MENU(ctx: Message | CallbackQuery): + return f"{ctx.from_user.first_name}, выберите локацию:" + + @staticmethod + async def MARKET_MENU(ctx: Message | CallbackQuery): + return f"{ctx.from_user.first_name}, выберите действие:" + + @staticmethod + async def INVENTORY_MENU(ctx: Message | CallbackQuery): + return f"{ctx.from_user.first_name}, выбери предмет:" \ No newline at end of file diff --git a/src/bot/db/database.py b/src/bot/db/database.py new file mode 100644 index 0000000..6c028b0 --- /dev/null +++ b/src/bot/db/database.py @@ -0,0 +1,29 @@ +from tortoise import Tortoise, run_async +from tortoise.exceptions import DBConnectionError +import logging, os +from ..config import ConfigService + +logger = logging.getLogger(__name__) + +TORTOISE_ORM = { + "connections": {"default": ConfigService.DATABASE_URL}, + "apps": { + "models": { + "models": ["src.bot.db.models", "aerich.models"], + "default_connection": "default", + }, + }, +} + +async def init_db(): + try: + await Tortoise.init(config=TORTOISE_ORM) + await Tortoise.generate_schemas() + logger.info('@ | Database connected and schemas generated successful!') + + except DBConnectionError as _ex: + logger.error(f"@ | Database connected failed: {_ex}") + raise + +async def close_db(): + await Tortoise.close_connections() diff --git a/src/bot/db/models.py b/src/bot/db/models.py new file mode 100644 index 0000000..1a672b5 --- /dev/null +++ b/src/bot/db/models.py @@ -0,0 +1,157 @@ +from tortoise import fields +from tortoise.models import Model +from enum import Enum + +class ItemTypeEnum(int, Enum): + WEAPON = 1 + ARMOR = 2 + ENCHANT = 3 + CASE = 4 + +class ItemRarityEnum(int, Enum): + COMMON = 1 + RARE = 2 + EPIC = 3 + LEGENDARY = 4 + +class EnemyTypeEnum(int, Enum): + COMMON = 1 + ELITE = 2 + BOSS = 3 + +# TODO : Сделать модель для локаций, продаваемых предметов на рынке +class Users(Model): + id = fields.BigIntField(pk=True) + telegram_id = fields.BigIntField(unique=True) + + username = fields.CharField(max_length=255, null=True) + first_name = fields.CharField(max_length=255, null=True) + last_name = fields.CharField(max_length=255, null=True) + + coins = fields.IntField(default=100) + exp = fields.IntField(default=0) + lvl = fields.IntField(default=0) + + sp_wins = fields.IntField(default=0) + sp_loses = fields.IntField(default=0) + + mp_wins = fields.IntField(default=0) + mp_losses = fields.IntField(default=0) + + elo = fields.IntField(default=0) + + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = 'users' + ordering = ['id', 'username'] + + @property + def __str__(self) -> str: + return f"" + +class Items(Model): + id = fields.BigIntField(pk=True) + + name = fields.CharField(max_length=225, null=False) + description = fields.TextField(max_length=512, null=True) + type = fields.IntEnumField(ItemTypeEnum) + attributes = fields.JSONField(default=dict) + rarity = fields.IntEnumField(ItemRarityEnum, default=ItemRarityEnum.COMMON) + + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = 'items' + ordering = ['id', 'name'] + + def __str__(self) -> str: + return f"" + +class InventoryItems(Model): + id = fields.BigIntField(pk=True) + + user = fields.ForeignKeyField('models.Users', related_name="inventory_items") + item = fields.ForeignKeyField('models.Items', related_name='instances') + quantity = fields.IntField(default=1) + equipped = fields.BooleanField(default=False) + + acquired_at = fields.DatetimeField(auto_now_add=True) + class Meta: + table = 'inventory_items' + unique_together = ('user', 'item') + + def __str__(self) -> str: + return f"" + +class Enemies(Model): + id = fields.BigIntField(pk=True) + + name = fields.CharField(max_length=255, null=False) + description = fields.TextField(max_length=512, null=True, default='') + + type = fields.IntEnumField(EnemyTypeEnum, default=EnemyTypeEnum.COMMON) + + health_multiplier = fields.FloatField(default=0.5) + damage_multiplier = fields.FloatField(default=0.5) + + coin_reward_multiplier = fields.FloatField(default=0.5) + exp_reward_multiplier = fields.FloatField(default=0.5) + + drop_chance = fields.FloatField(default=25.0, ge=0.0, le=100.0) + drop_items = fields.ManyToManyField( + "models.Items", + related_name='enemy_drops', + through='enemy_drop', + backward_key='enemy_id' + ) + + is_active = fields.BooleanField(default=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = 'enemies' + ordering = ['id', "name"] + + def __str__(self) -> str: + return f"" + +class Locations(Model): + id = fields.BigIntField(pk=True) + + name = fields.CharField(max_length=255) + description = fields.TextField() + + level_required = fields.IntField(default=1) + enemies = fields.ManyToManyField("models.Enemies", related_name="locations") + + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = 'locations' + ordering = ['id'] + + def __str__(self) -> str: + return f'' + +class MarketItem(Model): + id = fields.BigIntField(pk=True) + + item = fields.ForeignKeyField("models.Items") + seller = fields.ForeignKeyField("models.Users") + + price = fields.IntField() + + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = 'market_items' + ordering = ['id', 'item', 'price'] + + def __str__(self) -> str: + return f'' \ No newline at end of file diff --git a/src/bot/db/schemas/items.py b/src/bot/db/schemas/items.py new file mode 100644 index 0000000..158533b --- /dev/null +++ b/src/bot/db/schemas/items.py @@ -0,0 +1,32 @@ +from logging import critical +from pydantic import BaseModel, Field +from typing import Optional, List, Coroutine, Dict +from ..models import ItemTypeEnum, Items, ItemRarityEnum + + +class BaseAttributes(BaseModel): + pass + +class WeaponAttributes(BaseAttributes): + min_damage: int = Field(..., ge=1) + max_damage: int = Field(..., ge=1) + + attack_speed: float = Field(..., ge=1.0) + + critical_chance: float = Field(default=0.0, ge=0.0, le=1.0) + critical_multiplier: float = Field(default=1.0, ge=1.0, le=10.0) + +class ArmorAttributes(BaseAttributes): + defense: int = Field(..., ge=1) + + health_bonus: int = Field(ge=0, default=0) + +class EnchantAttributes(BaseAttributes): + for_type: ItemTypeEnum = Field(...) + +class CaseAttributes(BaseModel): + collection: str = Field(...) + storage: List[Dict[str, float]] = Field(...) + is_limited: bool = Field(default=False) + max_opens: Optional[int] = Field(default=None, ge=1) + \ No newline at end of file diff --git a/src/bot/game/config.py b/src/bot/game/config.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bot/game/logic/calculation.py b/src/bot/game/logic/calculation.py new file mode 100644 index 0000000..8eaf23a --- /dev/null +++ b/src/bot/game/logic/calculation.py @@ -0,0 +1,241 @@ +"""Модуль расчётов боевых характеристик: урон, броня, награды. + +Содержит калькуляторы для вычисления характеристик игрока и врага на основе +оборудования, уровня и множителей редкости. +""" +from typing import Dict +from ...db.models import InventoryItems, ItemRarityEnum, ItemTypeEnum, Users, Enemies, EnemyTypeEnum +from ...services.inventory import InventoryService +from ...db.schemas.items import WeaponAttributes, ArmorAttributes +from random import random, gauss, randint +from math import floor + +# TODO: Сделать классы для обработки общего урона-брони для используемых предметов + +class BaseCalculation: + """Базовый класс для всех калькуляторов боевых характеристик. + + Инициализирует сервис инвентаря для получения оборудованного оружия/брони. + """ + def __init__(self) -> None: + self.inventory_service = InventoryService() + +class DamageCalculation(BaseCalculation): + """Рассчитывает урон оружия игрока с учётом критов и множителя скорости атаки. + + Алгоритм: + 1. Получает оборудованное оружие из инвентаря + 2. Генерирует базовый урон в диапазоне [min_damage, max_damage] + 3. Применяет критический удар с вероятностью critical_chance + 4. Умножает на скорость атаки (attack_speed) + 5. Применяет бонус от уровня предмета (+2% за уровень) + """ + async def calculate_damage(self, user: Users) -> int: + """Вычисляет финальный урон оружия игрока. + + Args: + user (Users): объект игрока (содержит уровень) + + Returns: + int: итоговый урон (≥0). Возвращает 0, если оружие не экипировано. + + Примеры: + - Без оружия → 0 + - С оружием урон 10-15, крит 20%, мультипликатор 1.5, скорость 1.0 → ≈12-18 + """ + equipped_item = await self.inventory_service.get_equipped_item(user, ItemTypeEnum.WEAPON) + if not equipped_item or not equipped_item.item: + return 0 + + weapon_attributes = equipped_item.item.attributes if equipped_item.item.attributes else {} + + min_dmg = weapon_attributes.get("min_damage", 1) + max_dmg = weapon_attributes.get("max_damage", 1) + damage = randint(int(min_dmg), int(max_dmg)) + + crit_chance = weapon_attributes.get("critical_chance", 0.0) + if random() < crit_chance: + crit_mult = weapon_attributes.get("critical_multiplier", 1.0) + damage = int(damage * crit_mult) + + attack_speed = weapon_attributes.get("attack_speed", 1.0) + damage = int(damage * attack_speed) + + item_level = weapon_attributes.get("item_level", 0) + if item_level: + damage = int(damage * (1 + (item_level * 0.02))) + + return max(damage, 0) + + +class ArmorCalculation(BaseCalculation): + """Рассчитывает броню из экипированной брони с учётом бонусов и уровня. + + Формула брони включает: + - Базовое значение защиты (defense) из предмета + - Случайный бонус здоровья (health_bonus * random[0, 1]) + - Бонус от уровня предмета (+2% за уровень) + """ + async def calculate_armor(self, user: Users) -> float: + """Вычисляет финальную броню игрока. + + Args: + user (Users): объект игрока + + Returns: + float: значение брони (≥0). Возвращает 0, если броня не экипирована. + + Примеры: + - Без брони → 0 + - С бронёй defence=20, health_bonus=5 → 20-25 (с 50% дисперсией) + """ + equipped_item = await self.inventory_service.get_equipped_item(user, ItemTypeEnum.ARMOR) + if not equipped_item or not equipped_item.item: + return 0.0 + + armor_attributes = equipped_item.item.attributes if equipped_item.item.attributes else {} + + defense = armor_attributes.get("defense", 1) + armor = float(defense) + + health_bonus = armor_attributes.get("health_bonus", 0) + armor = armor * (1.0 + (health_bonus * random())) + + item_level = armor_attributes.get("item_level", 0) + if item_level: + armor = armor * (1.0 + (item_level * 0.02)) + + return max(armor, 0.0) + + +# TODO : Сделать рандомным, подключить userservice и сделать получение lvl. +# ! : Есть критические ошибки в данном сегменте! +class EnemyCalculator: + """Калькулятор статистики врага и наград за бой. + + Статистика врага зависит от: + - Типа врага (обычный, элита, босс) — множитель редкости + - Уровня игрока — базовый множитель + - Параметров врага (health_multiplier, damage_multiplier) + - Параметров игрока (урон оружия, броня) — влияют на расчёты + + Множители редкости: + COMMON: 1.0 (обычный враг) + ELITE: 2.3 (усиленный враг) + BOSS: 8.0 (боссовый враг) + """ + RARITY_BONUS = { + EnemyTypeEnum.COMMON: 1.0, + EnemyTypeEnum.ELITE: 2.3, + EnemyTypeEnum.BOSS: 8.0, + } + BASE_COIN = 1 + BASE_EXP = 1 + + async def get_stats(self, enemy: Enemies, user: Users) -> Dict[str, int]: + """Вычисляет статистику врага на основе его типа и параметров игрока. + + Формула HP врага: + hp = floor(user_level * health_multiplier * (1 + user_armor) * 0.85 * rarity_bonus * 0.85) + + Формула урона врага: + damage = floor(user_level * damage_multiplier * (1 + user_damage) * 0.85 * rarity_bonus * 0.85) + + Args: + enemy (Enemies): объект врага из БД (содержит множители) + user (Users): объект игрока (содержит уровень) + + Returns: + Dict[str, int]: словарь с ключами: + - 'hp': здоровье врага (≥1) + - 'damage': урон врага (≥1) + """ + bonus = self.RARITY_BONUS.get(enemy.type, 1.0) + + user_damage = await DamageCalculation().calculate_damage(user) + user_armor = await ArmorCalculation().calculate_armor(user) + + hp = floor( + user.lvl + * enemy.health_multiplier + * (1 + user_armor) * 0.85 + * bonus * 0.85 + ) + damage = floor( + user.lvl + * enemy.damage_multiplier + * (1 + user_damage) * 0.85 + * bonus * 0.85 + ) + + return { + "hp": max(hp, 1), + "damage": max(damage, 1), + } + + async def get_rewards(self, enemy: Enemies, player_level: int) -> Dict[str, int]: + """Рассчитывает награды за победу над врагом. + + Награда зависит от множителей врага и его редкости. + + Формула: + coin = floor(coin_reward_multiplier * BASE_COIN * rarity_bonus) + exp = floor(exp_reward_multiplier * BASE_EXP * rarity_bonus) + + Args: + enemy (Enemies): объект врага (содержит множители reward) + player_level (int): уровень игрока (не используется в текущей версии) + + Returns: + Dict[str, int]: словарь с ключами: + - 'coin': количество монет (≥1) + - 'exp': количество опыта (≥1) + + Примеры: + - Обычный враг с coin_reward_multiplier=10 → ≈10 монет + - Босс (8.0x) с coin_reward_multiplier=5 → ≈40 монет + """ + rarity_bonus = self.RARITY_BONUS.get(enemy.type, 1.0) + + coin = floor( + enemy.coin_reward_multiplier + * self.BASE_COIN + * rarity_bonus + ) + exp = floor( + enemy.exp_reward_multiplier + * self.BASE_EXP + * rarity_bonus + ) + + return { + "coin": max(coin, 1), + "exp": max(exp, 1), + } + + async def get_drop_chance(self, enemy: Enemies, player_level: int) -> float: + """Рассчитывает шанс дропа предметов от врага. + + Бонус к шансу дропа зависит от уровня игрока: + - На каждые 10 уровней + 5% шанса дропа (макс. +25% на уровне 50) + - Итоговый шанс не превышает 100% + + Формула: + bonus = min(player_level // 10, 5) * 5% + final_chance = min(100.0, enemy.drop_chance + bonus) + + Args: + enemy (Enemies): объект врага (содержит базовый drop_chance) + player_level (int): уровень игрока + + Returns: + float: шанс дропа в диапазоне [0.0, 100.0] (%) + + Примеры: + - Враг с drop_chance=25%, уровень игрока 5 → 25% (бонус =0) + - Враг с drop_chance=25%, уровень игрока 25 → 50% (бонус +25%) + - Враг с drop_chance=80%, уровень игрока 40 → 100% (ограничение) + """ + bonus = min(player_level // 10, 5) # +5% за каждые 10 уровней + return min(100.0, enemy.drop_chance + bonus) + \ No newline at end of file diff --git a/src/bot/game/views/battle.py b/src/bot/game/views/battle.py new file mode 100644 index 0000000..6132d80 --- /dev/null +++ b/src/bot/game/views/battle.py @@ -0,0 +1 @@ +from math import * diff --git a/src/bot/handlers/callback/battle/default.py b/src/bot/handlers/callback/battle/default.py new file mode 100644 index 0000000..a25ff04 --- /dev/null +++ b/src/bot/handlers/callback/battle/default.py @@ -0,0 +1,14 @@ +from aiogram import Router, F +from aiogram.types import CallbackQuery +from ....keyboards.inlines.battle import battle_menu + +callback_menu_battle = Router() + +@callback_menu_battle.callback_query(F.data == 'battle_menu') +async def cmd_battle_menu(callback: CallbackQuery): + await callback.message.edit_text(text=f'{callback.from_user.first_name}, выберите тип сражения:', reply_markup=await battle_menu()) + +@callback_menu_battle.callback_query(F.data == 'battle_multiplayer') +async def multiplayer_frame(callback: CallbackQuery): + await callback.message.edit_text(text=f'{callback.from_user.first_name}, чтобы начать дуэль, вам нужно ответить на любое сообщение игрока командой: /cometfall_duel') + diff --git a/src/bot/handlers/callback/battle/multiplayer/__init__.py b/src/bot/handlers/callback/battle/multiplayer/__init__.py new file mode 100644 index 0000000..3504e27 --- /dev/null +++ b/src/bot/handlers/callback/battle/multiplayer/__init__.py @@ -0,0 +1,4 @@ +from .deps import multiplayer_router as callback_multiplayer_router + + +__all__ = ('callback_statistics_router',) \ No newline at end of file diff --git a/src/bot/handlers/callback/battle/multiplayer/deps.py b/src/bot/handlers/callback/battle/multiplayer/deps.py new file mode 100644 index 0000000..aa81d80 --- /dev/null +++ b/src/bot/handlers/callback/battle/multiplayer/deps.py @@ -0,0 +1,11 @@ +from aiogram import Router + +from .....services.enemy import EnemyService +from .....services.inventory import InventoryService +from .....services.user import UserService + +multiplayer_router = Router() + +user_service = UserService() +inventory_service = InventoryService() +enemy_service = EnemyService() \ No newline at end of file diff --git a/src/bot/handlers/callback/battle/singleplayer/__init__.py b/src/bot/handlers/callback/battle/singleplayer/__init__.py new file mode 100644 index 0000000..6919813 --- /dev/null +++ b/src/bot/handlers/callback/battle/singleplayer/__init__.py @@ -0,0 +1,4 @@ +from .deps import singleplayer_router as callback_singleplayer_router +from . import view, helper # noqa: F401 + +__all__ = ('callback_singleplayer_router',) \ No newline at end of file diff --git a/src/bot/handlers/callback/battle/singleplayer/deps.py b/src/bot/handlers/callback/battle/singleplayer/deps.py new file mode 100644 index 0000000..c7be1e2 --- /dev/null +++ b/src/bot/handlers/callback/battle/singleplayer/deps.py @@ -0,0 +1,18 @@ +from aiogram import Router + +from .....services.enemy import EnemyService +from .....services.inventory import InventoryService +from .....services.user import UserService + +from .....game.logic.calculation import EnemyCalculator, EnemyTypeEnum, ArmorCalculation, DamageCalculation +from .....keyboards.inlines.battle import battle_menu, finished_singleplayer_fight, singleplayer_menu_location + +singleplayer_router = Router() + +user_service = UserService() +inventory_service = InventoryService() +enemy_service = EnemyService() + +enemy_calculator = EnemyCalculator() +armor_calculator = ArmorCalculation() +damage_calculator = DamageCalculation() \ No newline at end of file diff --git a/src/bot/handlers/callback/battle/singleplayer/helper.py b/src/bot/handlers/callback/battle/singleplayer/helper.py new file mode 100644 index 0000000..3451d58 --- /dev/null +++ b/src/bot/handlers/callback/battle/singleplayer/helper.py @@ -0,0 +1,153 @@ +"""Вспомогательные функции для системы боя 1vs1. + +Содержит функции поиска локаций, расчёт боя, формулы урона и брони. +""" +from .deps import enemy_service, user_service, inventory_service, enemy_calculator, armor_calculator, damage_calculator +from .....db.models import Users, Items, Locations, Enemies + + +class LocationNotFound(Exception): + """Raised when location cannot be found in database.""" + pass + + +async def find_location_by_name(location_name: str) -> Locations: + """Ищет локацию по имени. + + Args: + location_name (str): имя локации + + Returns: + Locations: найденная локация + + Raises: + LocationNotFound: если локация не найдена + """ + location = await Locations.get_or_none(name=location_name) + if not location: + raise LocationNotFound(f'Location with name "{location_name}" not found') + return location + +async def find_location_by_id(location_id: int) -> Locations: + """Ищет локацию по ID. + + Args: + location_id (int): ID локации + + Returns: + Locations: найденная локация + + Raises: + LocationNotFound: если локация не найдена + """ + location = await Locations.get_or_none(id=location_id) + if not location: + raise LocationNotFound(f'Location with id {location_id} not found') + + return location + +async def check_available_location(user: Users, location: Locations) -> bool: + """Проверяет, может ли игрок посетить локацию. + + Args: + user (Users): игрок + location (Locations): локация + + Returns: + bool: True если уровень игрока >= требуемому уровню локации + """ + # True if user's level >= location's required level + return user.lvl >= location.level_required + +def reduce_damage(raw_damage: float, target_armor: float, k: float = 80.0) -> float: + """Рассчитывает эффективный урон с учётом брони. + + Формула урона брони (Dota-подобная): + effective_dmg = raw_damage * (1 - armor / (armor + k)) + + Где k — постоянная, определяющая влияние брони (по умолчанию 80). + - Чем выше armor → тем ниже effective_damage + - armor = 0 → 100% урона + - armor = k → 50% урона + - armor → ∞ → 0% урона (асимптота) + + Args: + raw_damage (float): исходный урон до брони + target_armor (float): броня цели + k (float): константа для расчёта (по умолчанию 80) + + Returns: + float: эффективный урон после учёта брони + """ + +async def fighting(user: Users, location: Locations): + """Симулирует бой между игроком и врагом. + + Полный процесс боя: + 1. Проверяет доступность локации (уровень игрока) + 2. Спавнит врага в локации + 3. Получает статистику врага (HP, urón) + 4. Получает статистику игрока (урон оружия, броня) + 5. Рассчитывает эффективный урон (с учётом брони) + 6. Определяет победителя по числу ударов до K.O. + 7. Получает награды и шанс дропа + + Победитель = тот, кому требуется меньше ударов чтобы убить противника. + + Args: + user (Users): игрок, начинающий бой + location (Locations): локация боя + + Returns: + Dict: результат боя с ключами: + - 'winner': 'user' или 'enemy' + - 'enemy_hp', 'enemy_dmg': характеристики врага + - 'user_hp', 'user_dmg': характеристики игрока + - 'user_effective_dmg', 'enemy_effective_dmg': урон с учётом брони + - 'hits_to_kill_user', 'hits_to_kill_enemy': удары до смерти + - 'reward': словарь с coin и exp + - 'drop_chance': процент дропа (%) + - 'enemy': объект врага (для получения лута) + + Raises: + ValueError: если локация не доступна или нет врагов + """ + is_available = await check_available_location(user, location) + if not is_available: + raise ValueError(f"Location level {location.level_required} required. Current level: {user.lvl}") + + enemy = await enemy_service.spawn(user, location) + + enemy_stats = await enemy_calculator.get_stats(enemy, user) + enemy_hp = enemy_stats['hp'] + enemy_damage = enemy_stats['damage'] + + user_damage = await damage_calculator.calculate_damage(user) + user_hp = await armor_calculator.calculate_armor(user) + + user_effective_damage = reduce_damage(user_damage, user_hp) + enemy_effective_damage = reduce_damage(enemy_damage, enemy_hp) + + hits_to_kill_enemy = enemy_hp / max(user_effective_damage, 0.001) + hits_to_kill_user = user_hp / max(enemy_effective_damage, 0.001) + + user_wins = hits_to_kill_enemy <= hits_to_kill_user + + rewarding = await enemy_calculator.get_rewards(enemy, user.lvl) + drop_chance = await enemy_calculator.get_drop_chance(enemy, user.lvl) + + + return { + 'winner': 'user' if user_wins else 'enemy', + "enemy_hp": enemy_hp, + "enemy_dmg": enemy_damage, + "user_hp": user_hp, + "user_dmg": user_damage, + "user_effective_dmg": user_effective_damage, + "enemy_effective_dmg": enemy_effective_damage, + 'hits_to_kill_user': hits_to_kill_user, + 'hits_to_kill_enemy': hits_to_kill_enemy, + "reward": rewarding, + "drop_chance": drop_chance, + "enemy": enemy, + } \ No newline at end of file diff --git a/src/bot/handlers/callback/battle/singleplayer/view.py b/src/bot/handlers/callback/battle/singleplayer/view.py new file mode 100644 index 0000000..0f5c914 --- /dev/null +++ b/src/bot/handlers/callback/battle/singleplayer/view.py @@ -0,0 +1,37 @@ +from aiogram import F +from aiogram.types import CallbackQuery +from .deps import user_service, singleplayer_router, inventory_service, enemy_service, finished_singleplayer_fight, battle_menu, singleplayer_menu_location +from .helper import fighting, find_location_by_name, find_location_by_id +import re + +@singleplayer_router.callback_query(F.data == 'battle_singleplayer') +async def select_location(callback: CallbackQuery): + await callback.message.edit_text(text=f'{callback.from_user.first_name}, выберите локацию: ', reply_markup=await singleplayer_menu_location()) + +@singleplayer_router.callback_query(F.data.regexp(r'battle_singleplayer:start:(\d+)')) +async def start_singleplayer_battle(callback: CallbackQuery): + match = re.match(r"battle_singleplayer:start:(\d+)", callback.data) + if not match: + await callback.answer() + return + + user = await user_service.get_by_telegram_id(callback.from_user.id) + location = await find_location_by_id(int(match.group(1))) + result = await fighting(user, location) + + result_fight = 'вы выиграли' if result['winner'] == 'user' else 'вы проиграли' + + BASE_TEXT = f""" +{callback.from_user.first_name}, {result_fight}. Вам попался: {result['enemy'].name} + +Ваш урон: {result['user_dmg']} +Ваша броня: {result['user_hp']} + +Урон противника: {result['enemy_dmg']} +Броня противника: {result['enemy_hp']} + +За игру вы получили: {result['reward']['coin']} монет, {result['reward']['exp']} опыта +""" + await enemy_service.give_reward(user, result['enemy'], result['reward']['exp'], result['reward']['coin']) + await callback.message.edit_text(text=BASE_TEXT, reply_markup=await finished_singleplayer_fight()) + await callback.answer() \ No newline at end of file diff --git a/src/bot/handlers/callback/case/__init__.py b/src/bot/handlers/callback/case/__init__.py new file mode 100644 index 0000000..d335ebf --- /dev/null +++ b/src/bot/handlers/callback/case/__init__.py @@ -0,0 +1,4 @@ +"""Case handlers initialization.""" +from .handler import case_router + +__all__ = ["case_router"] diff --git a/src/bot/handlers/callback/case/handler.py b/src/bot/handlers/callback/case/handler.py new file mode 100644 index 0000000..e307f76 --- /dev/null +++ b/src/bot/handlers/callback/case/handler.py @@ -0,0 +1,171 @@ +"""Обработчик для открытия кейсов на торговой площадке. + +Интеграция: +- Добавление кейсов в каталог торговой площадки +- Покупка кейсов у продавца +- Открытие кейса и получение предмета +""" +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message +from tortoise.exceptions import DoesNotExist +from tortoise.transactions import in_transaction + +from src.bot.db.models import Users, Items, ItemTypeEnum, MarketItem, InventoryItems +from ....bot.services.case import CaseService +from ....bot.services.market import MarketService +from ....bot.services.user import UserService + +case_router = Router() + +# Сервисы +case_service = CaseService() +market_service = MarketService() +user_service = UserService() + + +@case_router.callback_query(F.data.startswith("open_case_")) +async def open_case_callback(callback: CallbackQuery, state: FSMContext): + """Открытие кейса из инвентаря (ТОЛЬКО по case_id, не inventory_item_id).""" + try: + # Извлекаем case_id из callback + case_id = int(callback.data.split("_")[2]) + + # Получаем пользователя + user = await Users.get(telegram_id=callback.from_user.id) + + # Получаем кейс по ID + case = await case_service.get_case(case_id) + + if not case: + await callback.answer("❌ Кейс не найден", show_alert=True) + return + + # Открываем кейс (атомарная операция в сервисе) + reward_item, message = await case_service.open_case(user, case) + + # Отправляем результат + await callback.message.edit_text( + text=( + f"🎁 **Открытие кейса: {case.name}**\n\n" + f"{message}\n\n" + ), + reply_markup=None + ) + await callback.answer("✨ Кейс открыт!", show_alert=False) + + except ValueError as e: + await callback.answer(f"❌ {str(e)}", show_alert=True) + except Exception as e: + await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + + +@case_router.callback_query(F.data.startswith("buy_case_")) +async def buy_case_callback(callback: CallbackQuery, state: FSMContext): + """Покупка кейса на торговой площадке.""" + try: + listing_id = int(callback.data.split("_")[2]) + user = await Users.get(telegram_id=callback.from_user.id) + + # Получаем объявление о продаже + listing = await MarketItem.get_or_none(id=listing_id) + if not listing: + await callback.answer("❌ Объявление не найдено", show_alert=True) + return + + # Проверяем, что это кейс + if listing.item.type != ItemTypeEnum.CASE: + await callback.answer("❌ Это не кейс", show_alert=True) + return + + # Проверяем баланс + if user.coins < listing.price: + await callback.answer( + f"❌ Недостаточно монет. Нужно: {listing.price}, У вас: {user.coins}", + show_alert=True + ) + return + + # Проверяем, что это не сам продавец + if listing.seller.id == user.id: + await callback.answer("❌ Вы не можете купить свой собственный предмет", show_alert=True) + return + + # Выполняем покупку (атомарная операция) + seller = listing.seller + case = listing.item + price = listing.price + + async with in_transaction(): + # Переводим монеты + user.coins -= price + seller.coins += price + await user.save() + await seller.save() + + # Добавляем кейс в инвентарь покупателя + await case_service.inventory_service.add(user, case, quantity=1) + + # Удаляем объявление + await listing.delete() + + await callback.message.edit_text( + text=( + f"✅ **Покупка успешна!**\n\n" + f"Вы купили: **{case.name}**\n" + f"Цена: **{price}** 💰\n\n" + f"Кейс добавлен в ваш инвентарь.\n" + f"Ваш баланс: {user.coins} 💰" + ), + reply_markup=None + ) + await callback.answer("✅ Кейс куплен!", show_alert=False) + + except ValueError as e: + await callback.answer(f"❌ {str(e)}", show_alert=True) + except Exception as e: + await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + + +@case_router.callback_query(F.data == "cases_catalog") +async def cases_catalog(callback: CallbackQuery, state: FSMContext): + """Каталог кейсов на торговой площадке.""" + try: + # Получаем все доступные кейсы + cases = await case_service.get_all_cases() + + if not cases: + await callback.message.edit_text( + text="📦 В каталоге нет кейсов", + reply_markup=None + ) + return + + # Формируем информацию о кейсах + cases_info = [] + for case in cases: + case_info = await case_service.get_case_info(case) + min_price = await market_service.get_min_price(case) + + if min_price: + cases_info.append( + f"📦 **{case_info['name']}** ({case_info['rarity']})\n" + f"Коллекция: {case_info['collection']}\n" + f"Предметов: {case_info['items_in_case']}\n" + f"Минцена: **{min_price}** 💰\n" + ) + + if not cases_info: + await callback.message.edit_text( + text="📦 На торговой площадке нет выставленных кейсов", + reply_markup=None + ) + return + + await callback.message.edit_text( + text="📦 **Каталог кейсов:**\n\n" + "\n".join(cases_info), + reply_markup=None + ) + + except Exception as e: + await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) diff --git a/src/bot/handlers/callback/inventory/__init__.py b/src/bot/handlers/callback/inventory/__init__.py new file mode 100644 index 0000000..b297c9f --- /dev/null +++ b/src/bot/handlers/callback/inventory/__init__.py @@ -0,0 +1,9 @@ +from .deps import inventory_router as callback_inventory_router + +from . import menu # noqa: F401 +from . import listing # noqa: F401 +from . import view # noqa: F401 +from . import sell # noqa: F401 + +__all__ = ("callback_inventory_router",) + diff --git a/src/bot/handlers/callback/inventory/deps.py b/src/bot/handlers/callback/inventory/deps.py new file mode 100644 index 0000000..3933928 --- /dev/null +++ b/src/bot/handlers/callback/inventory/deps.py @@ -0,0 +1,11 @@ +from aiogram import Router + +from ....services.inventory import InventoryService +from ....services.market import MarketService +from ....services.user import UserService + +inventory_router = Router() +inv_service = InventoryService() +market_service = MarketService() +user_service = UserService() + diff --git a/src/bot/handlers/callback/inventory/helpers.py b/src/bot/handlers/callback/inventory/helpers.py new file mode 100644 index 0000000..1150c16 --- /dev/null +++ b/src/bot/handlers/callback/inventory/helpers.py @@ -0,0 +1,118 @@ +from typing import List, Tuple + +from ....db.models import ItemTypeEnum +from ....keyboards.inlines.inventory import inventory_items_keyboard, inventory_type_keyboard +from ....services.inventory import InventoryService + +from .state import RARITY_ORDER + + +def item_type_slug(item_type: ItemTypeEnum) -> str: + if item_type == ItemTypeEnum.WEAPON: + return "weapon" + elif item_type == ItemTypeEnum.ARMOR: + return "armor" + elif item_type == ItemTypeEnum.CASE: + return "case" + else: + return "item" + + +def item_type_name(item_type: ItemTypeEnum) -> str: + if item_type == ItemTypeEnum.WEAPON: + return "оружия" + elif item_type == ItemTypeEnum.ARMOR: + return "брони" + elif item_type == ItemTypeEnum.CASE: + return "кейсов" + else: + return "предметов" + + +async def collect_items(user, item_type: ItemTypeEnum, inv_service: InventoryService) -> Tuple[List, int]: + all_items = await inv_service.get(user) + filtered = [i for i in all_items if i.item.type == item_type] + filtered.sort(key=lambda x: RARITY_ORDER.get(x.item.rarity, 99)) + total_pages = (len(filtered) + 4) // 5 if filtered else 0 + return filtered, total_pages + + +def format_item_detail_text(inv_item) -> str: + item = inv_item.item + attributes = item.attributes or {} + description = item.description or "Описание отсутствует." + + parts = [ + f"{item.name}:", + "", + f"Описание: {description}", + "", + ] + + # Обработка кейсов + if item.type == ItemTypeEnum.CASE: + collection = attributes.get("collection", "Неизвестно") + is_limited = attributes.get("is_limited", False) + max_opens = attributes.get("max_opens") + storage_count = len(attributes.get("storage", [])) + + parts += [ + f"Коллекция: {collection}", + f"Предметов в кейсе: {storage_count}", + f"Ограниченный: {'Да' if is_limited else 'Нет'}", + ] + if max_opens: + parts.append(f"Макс открытий: {max_opens}") + + # Обработка оружия + elif "min_damage" in attributes and "max_damage" in attributes: + min_damage = attributes.get("min_damage") + max_damage = attributes.get("max_damage") + attack_speed = attributes.get("attack_speed") + + avg_damage = None + if isinstance(min_damage, (int, float)) and isinstance(max_damage, (int, float)): + avg_damage = (min_damage + max_damage) / 2 + + parts += [ + f"Средний урон: {avg_damage:.1f}" if avg_damage is not None else "Средний урон: —", + f"Скорость атаки: {attack_speed:.2f}" if isinstance(attack_speed, (int, float)) else "Скорость атаки: —", + ] + + # Обработка брони + elif "defense" in attributes: + defense = attributes.get("defense") + health_bonus = attributes.get("health_bonus", 0) + + parts += [ + f"Защита: {defense}" if isinstance(defense, int) else "Защита: —", + f"Бонус к здоровью: {health_bonus}" if isinstance(health_bonus, int) else "Бонус к здоровью: —", + ] + + else: + parts.append("Характеристики: отсутствуют или неизвестный тип предмета.") + + parts += [ + "", + "Продать — выставить предмет на рынок и получить монеты." if item.type != ItemTypeEnum.CASE else "Открыть — получить случайный предмет из коллекции.", + ] + + return "\n".join(parts) + + +async def send_inventory_overview(message, user, item_type: ItemTypeEnum, item_slug: str, inv_service: InventoryService): + filtered, total_pages = await collect_items(user, item_type, inv_service) + display_name = user.first_name or user.username or "Игрок" + + if not filtered: + await message.answer( + text=f"{display_name}, у вас нет {item_type_name(item_type)}.", + reply_markup=inventory_type_keyboard(), + ) + return + + await message.answer( + text=f"{display_name}, ваша коллекция {item_type_name(item_type)}:", + reply_markup=inventory_items_keyboard(filtered, 0, total_pages, item_slug), + ) + diff --git a/src/bot/handlers/callback/inventory/listing.py b/src/bot/handlers/callback/inventory/listing.py new file mode 100644 index 0000000..9326788 --- /dev/null +++ b/src/bot/handlers/callback/inventory/listing.py @@ -0,0 +1,72 @@ +import re + +from aiogram import F +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery + +from ....db.models import ItemTypeEnum +from ....keyboards.inlines.inventory import inventory_items_keyboard, inventory_type_keyboard +from .deps import inventory_router, inv_service, user_service +from .helpers import collect_items, item_type_name +from .state import SellItemState + + +async def _show_inventory(callback: CallbackQuery, user, item_type: ItemTypeEnum, item_slug: str): + filtered, total_pages = await collect_items(user, item_type, inv_service) + display_name = callback.from_user.first_name or callback.from_user.username or "Игрок" + + if not filtered: + await callback.message.edit_text( + text=f"{display_name}, у вас нет {item_type_name(item_type)}.", + reply_markup=inventory_type_keyboard(), + ) + await callback.answer() + return + + await callback.message.edit_text( + text=f"{display_name}, ваша коллекция {item_type_name(item_type)}:", + reply_markup=inventory_items_keyboard(filtered, 0, total_pages, item_slug), + ) + await callback.answer() + + +@inventory_router.callback_query(F.data.startswith("inv_type_")) +async def callback_show_inventory(callback: CallbackQuery, state: FSMContext): + await state.clear() + item_slug = "weapon" if "weapon" in callback.data else "armor" + item_type = ItemTypeEnum.WEAPON if item_slug == "weapon" else ItemTypeEnum.ARMOR + user = await user_service.get_by_telegram_id(callback.from_user.id) + await _show_inventory(callback, user, item_type, item_slug) + + +@inventory_router.callback_query(F.data.regexp(r"inv_page_(\d+)_(weapon|armor)")) +async def callback_paginate_inventory(callback: CallbackQuery, state: FSMContext): + await state.clear() + match = re.match(r"inv_page_(\d+)_(weapon|armor)", callback.data) + if not match: + await callback.answer() + return + + page = int(match.group(1)) + item_slug = match.group(2) + item_type = ItemTypeEnum.WEAPON if item_slug == "weapon" else ItemTypeEnum.ARMOR + + user = await user_service.get_by_telegram_id(callback.from_user.id) + filtered, total_pages = await collect_items(user, item_type, inv_service) + display_name = callback.from_user.first_name or callback.from_user.username or "Игрок" + + if not filtered: + await callback.message.edit_text( + text=f"{display_name}, у вас нет {item_type_name(item_type)}.", + reply_markup=inventory_type_keyboard(), + ) + await callback.answer() + return + + page = max(0, min(page, total_pages - 1)) + await callback.message.edit_text( + text=f"{display_name}, ваша коллекция {item_type_name(item_type)}:", + reply_markup=inventory_items_keyboard(filtered, page, total_pages, item_slug), + ) + await callback.answer() + diff --git a/src/bot/handlers/callback/inventory/menu.py b/src/bot/handlers/callback/inventory/menu.py new file mode 100644 index 0000000..122fa94 --- /dev/null +++ b/src/bot/handlers/callback/inventory/menu.py @@ -0,0 +1,16 @@ +from aiogram import F +from aiogram.types import CallbackQuery + +from ....config import TelegramTextMap +from ....keyboards.inlines.inventory import inventory_type_keyboard +from .deps import inventory_router + + +@inventory_router.callback_query(F.data == "inv") +async def callback_inventory(callback: CallbackQuery): + await callback.message.edit_text( + text=await TelegramTextMap.INVENTORY_MENU(callback), + reply_markup=inventory_type_keyboard(), + ) + await callback.answer() + diff --git a/src/bot/handlers/callback/inventory/sell.py b/src/bot/handlers/callback/inventory/sell.py new file mode 100644 index 0000000..f61f49b --- /dev/null +++ b/src/bot/handlers/callback/inventory/sell.py @@ -0,0 +1,152 @@ +import re + +from aiogram import F +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from ....keyboards.inlines.inventory import ( + inventory_item_view_keyboard, + inventory_sell_back_keyboard, + inventory_sell_confirmation_keyboard, +) +from .deps import inventory_router, inv_service, market_service, user_service +from .helpers import format_item_detail_text, item_type_slug, send_inventory_overview +from .state import SellItemState + + +@inventory_router.callback_query(F.data.regexp(r"inv_sell_(\d+)")) +async def callback_sell_inventory_item(callback: CallbackQuery, state: FSMContext): + match = re.match(r"inv_sell_(\d+)", callback.data) + if not match: + await callback.answer() + return + + inventory_item_id = int(match.group(1)) + user = await user_service.get_by_telegram_id(callback.from_user.id) + inv_item = await inv_service.get_inventory_item(user, inventory_item_id) + + if not inv_item: + await callback.answer("Предмет не найден.", show_alert=True) + return + + min_price = await market_service.get_min_price(inv_item.item) + min_price_text = f"{min_price} мон." if min_price is not None else "нет предложений" + item_slug = item_type_slug(inv_item.item.type) + + await state.set_state(SellItemState.waiting_for_price) + await state.update_data(inventory_item_id=inv_item.id, item_slug=item_slug) + + await callback.message.answer( + text=( + f"Вы продаёте {inv_item.item.name}.\n" + f"Минимальная цена на рынке: {min_price_text}\n\n" + "Введите желаемую цену сообщением (целое число)." + ), + reply_markup=inventory_sell_back_keyboard(inv_item.id), + ) + await callback.answer() + + +@inventory_router.message(SellItemState.waiting_for_price) +async def inventory_sell_price_input(message: Message, state: FSMContext): + data = await state.get_data() + inventory_item_id = data.get("inventory_item_id") + + if not inventory_item_id: + await message.answer("Не удалось найти предмет. Попробуйте снова.") + await state.clear() + return + + user = await user_service.get_by_telegram_id(message.from_user.id) + inv_item = await inv_service.get_inventory_item(user, inventory_item_id) + + if not inv_item: + await message.answer("Предмет больше недоступен.") + await state.clear() + return + + price_text = (message.text or "").strip().replace(" ", "") + if not price_text.isdigit(): + await message.answer("Цена должна быть положительным числом. Попробуйте снова.") + return + + price = int(price_text) + if price <= 0: + await message.answer("Цена должна быть больше нуля. Попробуйте снова.") + return + + min_price = await market_service.get_min_price(inv_item.item) + min_price_text = f"{min_price} мон." if min_price is not None else "нет предложений" + + await state.update_data(price=price) + await state.set_state(SellItemState.waiting_for_confirmation) + + await message.answer( + text=( + f"Выставить {inv_item.item.name} за {price} монет?\n" + f"Минимальная цена на рынке: {min_price_text}\n\n" + "Подтвердите или отмените сделку." + ), + reply_markup=inventory_sell_confirmation_keyboard(), + ) + + +@inventory_router.callback_query(F.data == "inv_sell_cancel") +async def callback_cancel_inventory_sale(callback: CallbackQuery, state: FSMContext): + data = await state.get_data() + inventory_item_id = data.get("inventory_item_id") + await state.clear() + + if inventory_item_id: + user = await user_service.get_by_telegram_id(callback.from_user.id) + inv_item = await inv_service.get_inventory_item(user, inventory_item_id) + if inv_item: + item_slug = item_type_slug(inv_item.item.type) + await callback.message.edit_text( + text=format_item_detail_text(inv_item), + reply_markup=inventory_item_view_keyboard(inv_item.id, item_slug), + ) + await callback.answer("Продажа отменена.") + return + + await callback.message.edit_text("Продажа отменена.") + await callback.answer() + + +@inventory_router.callback_query(F.data == "inv_sell_confirm") +async def callback_confirm_inventory_sale(callback: CallbackQuery, state: FSMContext): + data = await state.get_data() + inventory_item_id = data.get("inventory_item_id") + price = data.get("price") + item_slug = data.get("item_slug") + + if not all([inventory_item_id, price, item_slug]): + await callback.answer("Нет активной продажи.", show_alert=True) + await state.clear() + return + + user = await user_service.get_by_telegram_id(callback.from_user.id) + inv_item = await inv_service.get_inventory_item(user, inventory_item_id) + + if not inv_item: + await callback.answer("Предмет больше недоступен.", show_alert=True) + await state.clear() + return + + await market_service.list_item(inv_item.item, user, price) + await inv_service.remove(user, inv_item.item, quantity=1) + await state.clear() + + await callback.message.edit_text( + text=f"{inv_item.item.name} выставлен на рынок за {price} монет." + ) + + await send_inventory_overview( + callback.message, + user, + inv_item.item.type, + item_slug, + inv_service, + ) + await callback.answer("Предмет размещён на рынке.") + diff --git a/src/bot/handlers/callback/inventory/state.py b/src/bot/handlers/callback/inventory/state.py new file mode 100644 index 0000000..67cc5eb --- /dev/null +++ b/src/bot/handlers/callback/inventory/state.py @@ -0,0 +1,16 @@ +from aiogram.fsm.state import State, StatesGroup + +from ....db.models import ItemRarityEnum + +RARITY_ORDER = { + ItemRarityEnum.LEGENDARY: 0, + ItemRarityEnum.EPIC: 1, + ItemRarityEnum.RARE: 2, + ItemRarityEnum.COMMON: 3, +} + + +class SellItemState(StatesGroup): + waiting_for_price = State() + waiting_for_confirmation = State() + diff --git a/src/bot/handlers/callback/inventory/view.py b/src/bot/handlers/callback/inventory/view.py new file mode 100644 index 0000000..ef86627 --- /dev/null +++ b/src/bot/handlers/callback/inventory/view.py @@ -0,0 +1,34 @@ +import re + +from aiogram import F +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery + +from ....keyboards.inlines.inventory import inventory_item_view_keyboard +from .deps import inventory_router, inv_service, user_service +from .helpers import format_item_detail_text, item_type_slug + + +@inventory_router.callback_query(F.data.regexp(r"inv_view_(\d+)")) +async def callback_view_inventory_item(callback: CallbackQuery, state: FSMContext): + await state.clear() + match = re.match(r"inv_view_(\d+)", callback.data) + if not match: + await callback.answer() + return + + inventory_item_id = int(match.group(1)) + user = await user_service.get_by_telegram_id(callback.from_user.id) + inv_item = await inv_service.get_inventory_item(user, inventory_item_id) + + if not inv_item: + await callback.answer("Предмет не найден.", show_alert=True) + return + + item_slug = item_type_slug(inv_item.item.type) + await callback.message.edit_text( + text=format_item_detail_text(inv_item), + reply_markup=inventory_item_view_keyboard(inv_item.id, item_slug), + ) + await callback.answer() + diff --git a/src/bot/handlers/callback/market/__init__.py b/src/bot/handlers/callback/market/__init__.py new file mode 100644 index 0000000..87e9a12 --- /dev/null +++ b/src/bot/handlers/callback/market/__init__.py @@ -0,0 +1,10 @@ +from .deps import market_router as callback_market_router + +# Import modules so handlers register on the router. +from . import menu # noqa: F401 +from . import catalog # noqa: F401 +from . import my_listings # noqa: F401 +from . import inputs # noqa: F401 + +__all__ = ("callback_market_router",) + diff --git a/src/bot/handlers/callback/market/catalog.py b/src/bot/handlers/callback/market/catalog.py new file mode 100644 index 0000000..96682ff --- /dev/null +++ b/src/bot/handlers/callback/market/catalog.py @@ -0,0 +1,164 @@ +import re +from typing import Dict, List, Tuple + +from aiogram import F +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery + +from ....db.models import Items +from ....keyboards.inlines.market import market_item_view_keyboard, market_items_keyboard, market_main_keyboard +from .deps import market_router, market_service, user_service +from .filters import format_filters_text, get_filters +from .helpers import ( + format_item_description, + item_type_from_slug, + price_matches, + rarity_slug_to_enum, + slug_from_item_type, +) +from .state import ITEMS_PER_PAGE, RARITY_LABELS + + +async def _filter_items(item_type, filters: Dict) -> Tuple[List[Items], Dict[int, int]]: + rarity_enum = rarity_slug_to_enum(filters.get("rarity")) + search_term = filters.get("search") or None + + items = await market_service.get_items(item_type, rarity=rarity_enum, search=search_term) + item_ids = [item.id for item in items] + min_price_map = await market_service.get_min_price_map(item_ids) + + if filters.get("min_price") is not None or filters.get("max_price") is not None: + items = [item for item in items if price_matches(min_price_map.get(item.id), filters)] + + return items, min_price_map + + +async def _show_items_list(callback: CallbackQuery, state: FSMContext, item_type_slug: str, page: int = 0): + filters = await get_filters(state) + item_type = item_type_from_slug(item_type_slug) + items, min_price_map = await _filter_items(item_type, filters) + total_pages = (len(items) + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE if items else 0 + + if not items: + await callback.message.edit_text( + text=( + f"{callback.from_user.first_name}, подходящих предметов не найдено.\n\n" + f"{format_filters_text(filters)}" + ), + reply_markup=market_main_keyboard(RARITY_LABELS.get(filters.get("rarity"), "все")), + ) + await callback.answer() + return + + page = max(0, min(page, total_pages - 1)) + await state.update_data(market_current_type=item_type_slug, market_last_page=page) + + await callback.message.edit_text( + text=( + f"{callback.from_user.first_name}, рынок ({'оружие' if item_type_slug == 'weapon' else 'броня'}).\n\n" + f"{format_filters_text(filters)}\n\n" + "Выберите предмет для подробностей:" + ), + reply_markup=market_items_keyboard(items, min_price_map, page, total_pages, item_type_slug), + ) + await callback.answer() + + +@market_router.callback_query(F.data == "market_type_weapon") +async def callback_market_weapon(callback: CallbackQuery, state: FSMContext): + await _show_items_list(callback, state, "weapon", page=0) + + +@market_router.callback_query(F.data == "market_type_armor") +async def callback_market_armor(callback: CallbackQuery, state: FSMContext): + await _show_items_list(callback, state, "armor", page=0) + + +@market_router.callback_query(F.data.regexp(r"market_page_(\d+)_(weapon|armor)")) +async def callback_market_page(callback: CallbackQuery, state: FSMContext): + match = re.match(r"market_page_(\d+)_(weapon|armor)", callback.data) + if not match: + await callback.answer() + return + page = int(match.group(1)) + item_type_slug = match.group(2) + await _show_items_list(callback, state, item_type_slug, page) + + +@market_router.callback_query(F.data.regexp(r"market_back_(weapon|armor)")) +async def callback_market_back(callback: CallbackQuery, state: FSMContext): + match = re.match(r"market_back_(weapon|armor)", callback.data) + if not match: + await callback.answer() + return + item_type_slug = match.group(1) + data = await state.get_data() + page = data.get("market_last_page", 0) + await _show_items_list(callback, state, item_type_slug, page) + + +@market_router.callback_query(F.data.regexp(r"market_view_item_(\d+)")) +async def callback_market_view_item(callback: CallbackQuery, state: FSMContext): + match = re.match(r"market_view_item_(\d+)", callback.data) + if not match: + await callback.answer() + return + item_id = int(match.group(1)) + item = await Items.get_or_none(id=item_id) + if not item: + await callback.answer("Предмет не найден.", show_alert=True) + return + + filters = await get_filters(state) + min_price = await market_service.get_min_price(item) + price_text = f"{min_price} мон." if min_price is not None else "нет активных лотов" + + await callback.message.edit_text( + text=( + f"{format_item_description(item)}\n\n" + f"Минимальная цена: {price_text}\n\n" + f"{format_filters_text(filters)}" + ), + reply_markup=market_item_view_keyboard(item.id, slug_from_item_type(item.type), min_price), + ) + await callback.answer() + + +@market_router.callback_query(F.data.regexp(r"market_buy_item_(\d+)")) +async def callback_market_buy_item(callback: CallbackQuery, state: FSMContext): + match = re.match(r"market_buy_item_(\d+)", callback.data) + if not match: + await callback.answer() + return + item_id = int(match.group(1)) + item = await Items.get_or_none(id=item_id) + if not item: + await callback.answer("Предмет не найден.", show_alert=True) + return + + user = await user_service.get_by_telegram_id(callback.from_user.id) + if not user: + await callback.answer("Пользователь не найден.", show_alert=True) + return + + try: + price_paid = await market_service.buy_cheapest_listing(item, user) + except ValueError as exc: + await callback.answer(str(exc), show_alert=True) + return + + min_price = await market_service.get_min_price(item) + filters = await get_filters(state) + min_price_text = min_price if min_price is not None else "нет активных лотов" + + await callback.message.edit_text( + text=( + f"Покупка успешна! Вы приобрели {item.name} за {price_paid} монет.\n\n" + f"{format_item_description(item)}\n\n" + f"Минимальная цена: {min_price_text}\n\n" + f"{format_filters_text(filters)}" + ), + reply_markup=market_item_view_keyboard(item.id, slug_from_item_type(item.type), min_price), + ) + await callback.answer("Сделка завершена.") + diff --git a/src/bot/handlers/callback/market/deps.py b/src/bot/handlers/callback/market/deps.py new file mode 100644 index 0000000..8e12b7b --- /dev/null +++ b/src/bot/handlers/callback/market/deps.py @@ -0,0 +1,9 @@ +from aiogram import Router + +from ....services.market import MarketService +from ....services.user import UserService + +market_router = Router() +market_service = MarketService() +user_service = UserService() + diff --git a/src/bot/handlers/callback/market/filters.py b/src/bot/handlers/callback/market/filters.py new file mode 100644 index 0000000..650ee0d --- /dev/null +++ b/src/bot/handlers/callback/market/filters.py @@ -0,0 +1,55 @@ +from typing import Dict, Optional, Union + +from aiogram.fsm.context import FSMContext + +from .state import RARITY_LABELS + +FilterValue = Optional[Union[str, int]] + +DEFAULT_FILTERS: Dict[str, FilterValue] = { + "search": "", + "rarity": "all", + "min_price": 0, + "max_price": None, +} + + +def format_filters_text(filters: Dict[str, FilterValue]) -> str: + search = filters.get("search") or "—" + rarity = RARITY_LABELS.get(filters.get("rarity"), "все") + + min_price = filters.get("min_price") + max_price = filters.get("max_price") + if min_price is None and max_price is None: + price_text = "—" + else: + min_part = str(min_price) if min_price is not None else "0" + max_part = str(max_price) if max_price is not None else "∞" + price_text = f"{min_part}-{max_part}" + + return f"Поиск: {search}\nРедкость: {rarity}\nЦена: {price_text}" + + +async def get_filters(state: FSMContext) -> Dict[str, FilterValue]: + data = await state.get_data() + filters = data.get("market_filters") + if not filters: + filters = DEFAULT_FILTERS.copy() + await state.update_data(market_filters=filters) + return filters + + +async def set_filters(state: FSMContext, **updates: FilterValue) -> Dict[str, FilterValue]: + filters = await get_filters(state) + for key, value in updates.items(): + if key in filters: + filters[key] = value + await state.update_data(market_filters=filters) + return filters + + +async def reset_filters(state: FSMContext) -> Dict[str, FilterValue]: + filters = DEFAULT_FILTERS.copy() + await state.update_data(market_filters=filters) + return filters + diff --git a/src/bot/handlers/callback/market/helpers.py b/src/bot/handlers/callback/market/helpers.py new file mode 100644 index 0000000..1a959d1 --- /dev/null +++ b/src/bot/handlers/callback/market/helpers.py @@ -0,0 +1,87 @@ +from typing import Dict, List, Optional, Tuple + +from ....db.models import ItemRarityEnum, ItemTypeEnum, Items + +from .state import RARITY_SEQUENCE + + +def rarity_slug_to_enum(slug: str) -> Optional[ItemRarityEnum]: + mapping = { + "common": ItemRarityEnum.COMMON, + "rare": ItemRarityEnum.RARE, + "epic": ItemRarityEnum.EPIC, + "legendary": ItemRarityEnum.LEGENDARY, + } + return mapping.get(slug) + + +def item_type_from_slug(slug: str) -> ItemTypeEnum: + return ItemTypeEnum.WEAPON if slug == "weapon" else ItemTypeEnum.ARMOR + + +def slug_from_item_type(item_type: ItemTypeEnum) -> str: + return "weapon" if item_type == ItemTypeEnum.WEAPON else "armor" + + +def price_matches(price: Optional[int], filters: Dict[str, Optional[int | str]]) -> bool: + min_price = filters.get("min_price") + max_price = filters.get("max_price") + + if min_price is None and max_price is None: + return True + if price is None: + return False + if min_price is not None and price < min_price: + return False + if max_price is not None and price > max_price: + return False + return True + + +def next_rarity_slug(current: str) -> str: + if current not in RARITY_SEQUENCE: + return RARITY_SEQUENCE[0] + idx = RARITY_SEQUENCE.index(current) + return RARITY_SEQUENCE[(idx + 1) % len(RARITY_SEQUENCE)] + + +def format_item_description(item: Items) -> str: + attrs = item.attributes or {} + description = item.description or "Описание отсутствует." + + text = [ + f"{item.name}:\n", + f"Описание: {description}\n", + ] + + if "min_damage" in attrs and "max_damage" in attrs: + min_damage = attrs.get("min_damage") + max_damage = attrs.get("max_damage") + attack_speed = attrs.get("attack_speed") + + avg_damage = None + if isinstance(min_damage, (int, float)) and isinstance(max_damage, (int, float)): + avg_damage = (min_damage + max_damage) / 2 + + avg_damage_text = f"{avg_damage:.1f}" if avg_damage is not None else "—" + attack_speed_text = f"{attack_speed:.2f}" if isinstance(attack_speed, (int, float)) else "—" + + text += [ + f"Средний урон: {avg_damage_text}", + f"Скорость атаки: {attack_speed_text}", + ] + + elif "defense" in attrs: + defense = attrs.get("defense") + health_bonus = attrs.get("health_bonus", 0) + + text += [ + f"Защита: {defense if isinstance(defense, int) else '—'}", + f"Бонус к здоровью: {health_bonus if isinstance(health_bonus, int) else '—'}", + ] + + else: + text.append("Характеристики: отсутствуют") + + return "\n".join(text) + diff --git a/src/bot/handlers/callback/market/inputs.py b/src/bot/handlers/callback/market/inputs.py new file mode 100644 index 0000000..a48c593 --- /dev/null +++ b/src/bot/handlers/callback/market/inputs.py @@ -0,0 +1,72 @@ +from aiogram import F +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from .deps import market_router +from .filters import set_filters +from .menu import send_market_menu +from .state import MarketFilterState + + +@market_router.callback_query(F.data == "market_search") +async def callback_market_search(callback: CallbackQuery, state: FSMContext): + await state.set_state(MarketFilterState.waiting_for_search) + await callback.message.answer("Введите название предмета для поиска или '-' чтобы сбросить.") + await callback.answer() + + +@market_router.message(MarketFilterState.waiting_for_search) +async def market_search_input(message: Message, state: FSMContext): + text = (message.text or "").strip() + value = "" if text == "-" else text + filters = await set_filters(state, search=value) + await state.set_state(None) + await message.answer("Поиск обновлён.") + await send_market_menu(message, filters) + + +@market_router.callback_query(F.data == "market_set_min_price") +async def callback_market_set_min_price(callback: CallbackQuery, state: FSMContext): + await state.set_state(MarketFilterState.waiting_for_min_price) + await callback.message.answer("Введите минимальную цену (число) или '-' чтобы очистить.") + await callback.answer() + + +@market_router.message(MarketFilterState.waiting_for_min_price) +async def market_min_price_input(message: Message, state: FSMContext): + text = (message.text or "").strip() + if text == "-": + filters = await set_filters(state, min_price=None) + elif text.isdigit(): + filters = await set_filters(state, min_price=int(text)) + else: + await message.answer("Введите положительное число или '-' для сброса.") + return + + await state.set_state(None) + await message.answer("Минимальная цена обновлена.") + await send_market_menu(message, filters) + + +@market_router.callback_query(F.data == "market_set_max_price") +async def callback_market_set_max_price(callback: CallbackQuery, state: FSMContext): + await state.set_state(MarketFilterState.waiting_for_max_price) + await callback.message.answer("Введите максимальную цену (число) или '-' чтобы очистить.") + await callback.answer() + + +@market_router.message(MarketFilterState.waiting_for_max_price) +async def market_max_price_input(message: Message, state: FSMContext): + text = (message.text or "").strip() + if text == "-": + filters = await set_filters(state, max_price=None) + elif text.isdigit(): + filters = await set_filters(state, max_price=int(text)) + else: + await message.answer("Введите положительное число или '-' для сброса.") + return + + await state.set_state(None) + await message.answer("Максимальная цена обновлена.") + await send_market_menu(message, filters) + diff --git a/src/bot/handlers/callback/market/menu.py b/src/bot/handlers/callback/market/menu.py new file mode 100644 index 0000000..cdc6ec5 --- /dev/null +++ b/src/bot/handlers/callback/market/menu.py @@ -0,0 +1,65 @@ +from aiogram import F +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from ....keyboards.inlines.market import market_main_keyboard +from .deps import market_router +from .filters import format_filters_text, get_filters, reset_filters, set_filters +from .helpers import next_rarity_slug +from .state import RARITY_LABELS + + +async def send_market_menu(message: Message, filters: dict) -> None: + await message.answer( + text=( + "🏪 Рынок Кометопада\n\n" + "Настройки фильтров:\n" + f"{format_filters_text(filters)}\n\n" + "Выберите категорию или обновите фильтры:" + ), + reply_markup=market_main_keyboard(RARITY_LABELS.get(filters.get("rarity"), "все")), + ) + + +async def edit_market_menu(callback: CallbackQuery, filters: dict) -> None: + await callback.message.edit_text( + text=( + f"{callback.from_user.first_name}, добро пожаловать на рынок.\n\n" + "Текущие фильтры:\n" + f"{format_filters_text(filters)}\n\n" + "Выберите действие:" + ), + reply_markup=market_main_keyboard(RARITY_LABELS.get(filters.get("rarity"), "все")), + ) + + +@market_router.callback_query(F.data == "market") +async def callback_market_entry(callback: CallbackQuery, state: FSMContext): + await state.clear() + filters = await reset_filters(state) + await edit_market_menu(callback, filters) + await callback.answer() + + +@market_router.callback_query(F.data == "market_menu") +async def callback_market_menu(callback: CallbackQuery, state: FSMContext): + filters = await get_filters(state) + await edit_market_menu(callback, filters) + await callback.answer() + + +@market_router.callback_query(F.data == "market_cycle_rarity") +async def callback_market_cycle_rarity(callback: CallbackQuery, state: FSMContext): + filters = await get_filters(state) + next_slug = next_rarity_slug(filters.get("rarity", "all")) + filters = await set_filters(state, rarity=next_slug) + await edit_market_menu(callback, filters) + await callback.answer("Редкость обновлена.") + + +@market_router.callback_query(F.data == "market_filters_reset") +async def callback_market_filters_reset(callback: CallbackQuery, state: FSMContext): + filters = await reset_filters(state) + await edit_market_menu(callback, filters) + await callback.answer("Фильтры сброшены.") + diff --git a/src/bot/handlers/callback/market/my_listings.py b/src/bot/handlers/callback/market/my_listings.py new file mode 100644 index 0000000..944acf2 --- /dev/null +++ b/src/bot/handlers/callback/market/my_listings.py @@ -0,0 +1,124 @@ +import re + +from aiogram import F +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery + +from ....db.models import MarketItem +from ....keyboards.inlines.market import market_main_keyboard, market_my_listing_view_keyboard, market_my_listings_keyboard +from .deps import market_router, market_service, user_service +from .filters import get_filters +from .helpers import format_item_description +from .state import LISTINGS_PER_PAGE, RARITY_LABELS + + +@market_router.callback_query(F.data == "market_my_listings") +async def callback_market_my_listings(callback: CallbackQuery, state: FSMContext): + user = await user_service.get_by_telegram_id(callback.from_user.id) + listings = await market_service.get_player_listings(user) + + total_pages = (len(listings) + LISTINGS_PER_PAGE - 1) // LISTINGS_PER_PAGE if listings else 0 + await state.update_data(market_my_page=0) + + if not listings: + filters = await get_filters(state) + await callback.message.edit_text( + text="У вас нет активных лотов. Используйте инвентарь, чтобы выставить предметы.", + reply_markup=market_main_keyboard(RARITY_LABELS.get(filters.get("rarity"), "все")), + ) + await callback.answer() + return + + await callback.message.edit_text( + text="Ваши активные лоты:", + reply_markup=market_my_listings_keyboard(listings, 0, total_pages), + ) + await callback.answer() + + +@market_router.callback_query(F.data.regexp(r"market_my_page_(\d+)")) +async def callback_market_my_page(callback: CallbackQuery, state: FSMContext): + match = re.match(r"market_my_page_(\d+)", callback.data) + if not match: + await callback.answer() + return + page = int(match.group(1)) + + user = await user_service.get_by_telegram_id(callback.from_user.id) + listings = await market_service.get_player_listings(user) + if not listings: + await callback.answer("У вас нет активных лотов.", show_alert=True) + return + + total_pages = (len(listings) + LISTINGS_PER_PAGE - 1) // LISTINGS_PER_PAGE + page = max(0, min(page, total_pages - 1)) + await state.update_data(market_my_page=page) + + await callback.message.edit_text( + text="Ваши активные лоты:", + reply_markup=market_my_listings_keyboard(listings, page, total_pages), + ) + await callback.answer() + + +@market_router.callback_query(F.data.regexp(r"market_my_view_(\d+)")) +async def callback_market_my_view(callback: CallbackQuery, state: FSMContext): + match = re.match(r"market_my_view_(\d+)", callback.data) + if not match: + await callback.answer() + return + listing_id = int(match.group(1)) + + user = await user_service.get_by_telegram_id(callback.from_user.id) + listing = await MarketItem.filter(id=listing_id, seller=user).prefetch_related("item").first() + + if not listing: + await callback.answer("Лот не найден.", show_alert=True) + return + + await callback.message.edit_text( + text=( + f"Ваш лот:\n\n" + f"{format_item_description(listing.item)}\n\n" + f"Цена: {listing.price} монет." + ), + reply_markup=market_my_listing_view_keyboard(listing.id), + ) + await callback.answer() + + +@market_router.callback_query(F.data.regexp(r"market_my_remove_(\d+)")) +async def callback_market_my_remove(callback: CallbackQuery, state: FSMContext): + match = re.match(r"market_my_remove_(\d+)", callback.data) + if not match: + await callback.answer() + return + listing_id = int(match.group(1)) + + user = await user_service.get_by_telegram_id(callback.from_user.id) + removed = await market_service.remove_listing(listing_id, user) + if not removed: + await callback.answer("Лот не найден.", show_alert=True) + return + + listings = await market_service.get_player_listings(user) + total_pages = (len(listings) + LISTINGS_PER_PAGE - 1) // LISTINGS_PER_PAGE if listings else 0 + data = await state.get_data() + page = min(data.get("market_my_page", 0), max(total_pages - 1, 0)) if listings else 0 + + if not listings: + filters = await get_filters(state) + await callback.message.edit_text( + text="Вы сняли последний лот. Рынок ждёт новых предложений!", + reply_markup=market_main_keyboard(RARITY_LABELS.get(filters.get("rarity"), "все")), + ) + await callback.answer("Лот снят с продажи.") + return + + await state.update_data(market_my_page=page) + await callback.message.edit_text( + text="Ваши активные лоты:", + reply_markup=market_my_listings_keyboard(listings, page, total_pages), + ) + await callback.answer("Лот снят с продажи.") + diff --git a/src/bot/handlers/callback/market/state.py b/src/bot/handlers/callback/market/state.py new file mode 100644 index 0000000..06bcf87 --- /dev/null +++ b/src/bot/handlers/callback/market/state.py @@ -0,0 +1,20 @@ +from aiogram.fsm.state import State, StatesGroup + +ITEMS_PER_PAGE = 5 +LISTINGS_PER_PAGE = 5 + +RARITY_SEQUENCE = ["all", "common", "rare", "epic", "legendary"] +RARITY_LABELS = { + "all": "все", + "common": "обычн.", + "rare": "редк.", + "epic": "эпич.", + "legendary": "легенд.", +} + + +class MarketFilterState(StatesGroup): + waiting_for_search = State() + waiting_for_min_price = State() + waiting_for_max_price = State() + diff --git a/src/bot/handlers/callback/statistics/__init__.py b/src/bot/handlers/callback/statistics/__init__.py new file mode 100644 index 0000000..93fc3e2 --- /dev/null +++ b/src/bot/handlers/callback/statistics/__init__.py @@ -0,0 +1,4 @@ +from .deps import statistics_router as callback_statistics_router +from . import view # noqa: F401 + +__all__ = ('callback_statistics_router',) \ No newline at end of file diff --git a/src/bot/handlers/callback/statistics/deps.py b/src/bot/handlers/callback/statistics/deps.py new file mode 100644 index 0000000..7f4973d --- /dev/null +++ b/src/bot/handlers/callback/statistics/deps.py @@ -0,0 +1,9 @@ +from aiogram import Router + +from ....services.inventory import InventoryService +from ....services.user import UserService + +statistics_router = Router() + +user_service = UserService() +inventory_service = InventoryService() \ No newline at end of file diff --git a/src/bot/handlers/callback/statistics/view.py b/src/bot/handlers/callback/statistics/view.py new file mode 100644 index 0000000..00a1e4a --- /dev/null +++ b/src/bot/handlers/callback/statistics/view.py @@ -0,0 +1,43 @@ +from aiogram.types import CallbackQuery +from aiogram import F +from .deps import statistics_router, user_service, inventory_service +from ....keyboards.inlines.statistics import get_statistic_keyboard +from ....db.models import ItemTypeEnum, ItemRarityEnum + +RARITY_NAMES = { + ItemRarityEnum.COMMON: "Обычное", + ItemRarityEnum.RARE: "Редкое", + ItemRarityEnum.EPIC: "Эпическое", + ItemRarityEnum.LEGENDARY: "Легендарное", +} + +@statistics_router.callback_query(F.data == 'stats') +async def view_user_statistics(callback: CallbackQuery): + user = await user_service.get_by_telegram_id(callback.from_user.id) + + equipped_sword = await inventory_service.get_equipped_item(user, ItemTypeEnum.WEAPON) + equipped_armor = await inventory_service.get_equipped_item(user, ItemTypeEnum.ARMOR) + + sword_info = f"{RARITY_NAMES.get(equipped_sword.item.rarity, 'Неизвестно')} | {equipped_sword.item.name}" if equipped_sword else "Не экипировано" + armor_info = f"{RARITY_NAMES.get(equipped_armor.item.rarity, 'Неизвестно')} | {equipped_armor.item.name}" if equipped_armor else "Не экипировано" + + message = f""" +{callback.from_user.first_name}, ваша статистика: +=========================== +Монет: {user.coins} +Уровень: {user.lvl} +Опыт: {user.exp} +=========================== +Побед (в режиме приключений): {user.sp_wins} +Поражений (в режиме приключений): {user.sp_loses} +=========================== +ELO: {user.elo} +Побед (в режиме дуэль): {user.mp_wins} +Поражений (в режиме дуэль): {user.mp_loses} +=========================== +Ваше оружие: {sword_info} +Ваша броня: {armor_info} +""" + + await callback.message.edit_text(message, reply_markup=await get_statistic_keyboard()) + await callback.answer() \ No newline at end of file diff --git a/src/bot/handlers/default.py b/src/bot/handlers/default.py new file mode 100644 index 0000000..0fbc961 --- /dev/null +++ b/src/bot/handlers/default.py @@ -0,0 +1,34 @@ +from aiogram import Router, F +from aiogram.filters import Command, CommandStart +from aiogram.types import Message, CallbackQuery +from ..services.user import UserService +from ..config import TelegramTextMap +from ..keyboards.inlines.default import mainmenu_keyboard + +default_router = Router() +_user_service = UserService() + +@default_router.message(CommandStart()) +async def command_start(message: Message) -> None: + user = await _user_service.create( + telegram_id=message.from_user.id, + username=message.from_user.username or '', + first_name=message.from_user.first_name or '', + last_name=message.from_user.last_name or '' + ) + await message.reply( + text=await TelegramTextMap.GREETING_TEXT(message), + reply_markup=await mainmenu_keyboard() + ) + +@default_router.callback_query(F.data == "mainmenu") +async def callback_mainmenu(callback: CallbackQuery) -> None: + user = await _user_service.get_by_telegram_id(callback.from_user.id) + if not user: + await callback.answer("Пользователь не найден", show_alert=True) + return + await callback.message.edit_text( + text=await TelegramTextMap.MAINMENU_TEXT(callback), + reply_markup=await mainmenu_keyboard() + ) + \ No newline at end of file diff --git a/src/bot/keyboards/inlines/battle.py b/src/bot/keyboards/inlines/battle.py new file mode 100644 index 0000000..52c6d79 --- /dev/null +++ b/src/bot/keyboards/inlines/battle.py @@ -0,0 +1,41 @@ +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder +from ...db.models import Locations + +async def battle_menu() -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + + kb.button(text='С ботами', callback_data="battle_singleplayer") + kb.button(text='Дуэль', callback_data="battle_multiplayer") + + kb.button(text='Назад', callback_data="mainmenu") + + return kb.adjust(1).as_markup() + +async def singleplayer_menu_location() -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + + locations = await Locations.all() + + for loc in locations: + kb.button(text=loc.name, callback_data=f"battle_singleplayer:start:{loc.id}") + + kb.button(text="Назад", callback_data="battle_menu") + + return kb.adjust(1).as_markup() + +async def confirm_singleplayer_battle() -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + + kb.button(text='Да', callback_data='battle_singleplayer:confirm') + kb.button(text='Нет', callback_data="battle_singleplayer:cancel") + + return kb.adjust(1).as_markup() + +async def finished_singleplayer_fight() -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + + kb.button(text='Заново', callback_data='battle_singleplayer') + kb.button(text='Назад', callback_data='battle_menu') + + return kb.adjust(2).as_markup() \ No newline at end of file diff --git a/src/bot/keyboards/inlines/case.py b/src/bot/keyboards/inlines/case.py new file mode 100644 index 0000000..a1d58f7 --- /dev/null +++ b/src/bot/keyboards/inlines/case.py @@ -0,0 +1,81 @@ +"""Клавиатуры для кейсов.""" +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from typing import List + +from src.bot.db.models import Items + + +def cases_inventory_keyboard(cases: List[Items]) -> InlineKeyboardMarkup: + """Клавиатура для открытия кейсов из инвентаря. + + Args: + cases: список кейсов в инвентаре + + Returns: + InlineKeyboardMarkup: клавиатура с кнопками открытия кейсов + """ + buttons = [] + + for case in cases: + buttons.append([ + InlineKeyboardButton( + text=f"🎁 {case.name}", + callback_data=f"open_case_{case.id}" + ) + ]) + + buttons.append([ + InlineKeyboardButton( + text="⬅️ Назад", + callback_data="inventory_menu" + ) + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def case_market_keyboard(case_id: int) -> InlineKeyboardMarkup: + """Клавиатура для покупки кейса на рынке. + + Args: + case_id: ID кейса + + Returns: + InlineKeyboardMarkup: клавиатура с кнопкой покупки + """ + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="💳 Купить", + callback_data=f"buy_case_{case_id}" + ), + InlineKeyboardButton( + text="ℹ️ Подробнее", + callback_data=f"case_info_{case_id}" + ) + ], + [ + InlineKeyboardButton( + text="⬅️ Назад", + callback_data="market_menu" + ) + ] + ]) + + +def cases_catalog_keyboard() -> InlineKeyboardMarkup: + """Клавиатура для каталога кейсов.""" + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="📦 Каталог кейсов", + callback_data="cases_catalog" + ) + ], + [ + InlineKeyboardButton( + text="⬅️ На рынок", + callback_data="market_menu" + ) + ] + ]) diff --git a/src/bot/keyboards/inlines/default.py b/src/bot/keyboards/inlines/default.py new file mode 100644 index 0000000..081bad2 --- /dev/null +++ b/src/bot/keyboards/inlines/default.py @@ -0,0 +1,14 @@ +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder +from ...config import TelegramTextMap + + +async def mainmenu_keyboard() -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + + kb.button(text='Начать сражение', callback_data='battle_menu') + kb.button(text='Инвентарь', callback_data='inv') + kb.button(text='Рынок', callback_data='market') + kb.button(text='Статистика', callback_data='stats') + + return kb.adjust(1).as_markup() \ No newline at end of file diff --git a/src/bot/keyboards/inlines/inventory.py b/src/bot/keyboards/inlines/inventory.py new file mode 100644 index 0000000..1bfc738 --- /dev/null +++ b/src/bot/keyboards/inlines/inventory.py @@ -0,0 +1,78 @@ +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from ...db.models import ItemRarityEnum + +RARITY_EMOJI = { + ItemRarityEnum.COMMON: "⬜", + ItemRarityEnum.RARE: "🟦", + ItemRarityEnum.EPIC: "🟪", + ItemRarityEnum.LEGENDARY: "🟨" +} + +def inventory_type_keyboard() -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + kb.button(text="🗡️ Оружие", callback_data="inv_type_weapon") + kb.button(text="🛡️ Броня", callback_data="inv_type_armor") + kb.button(text="🎁 Кейсы", callback_data="inv_type_case") + kb.button(text="◀️ Назад", callback_data="mainmenu") + kb.adjust(2, 1, 1) + return kb.as_markup() + +def inventory_items_keyboard(items: list, page: int = 0, total_pages: int = 1, item_type: str = "armor") -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + + start = page * 5 + end = start + 5 + page_items = items[start:end] + + for idx, inv_item in enumerate(page_items, start + 1): + item = inv_item.item + rarity_emoji = RARITY_EMOJI.get(item.rarity, "⬜") + equipped = "⚡" if inv_item.equipped else "" + kb.button( + text=f"{idx}. {rarity_emoji} | {item.name} (x{inv_item.quantity}) {equipped}", + callback_data=f"inv_view_{inv_item.id}" + ) + + # Пагинация + if page > 0: + kb.button(text="⬅️ Предыдущая", callback_data=f"inv_page_{page-1}_{item_type}") + if page < total_pages - 1: + kb.button(text="➡️ Следующая", callback_data=f"inv_page_{page+1}_{item_type}") + + # Нижняя строка + kb.row( + InlineKeyboardButton(text="🔍 Фильтр", callback_data=f"inv_filter_{item_type}"), + InlineKeyboardButton(text="◀️ Назад", callback_data="inv") + ) + kb.adjust(1, 2) + return kb.as_markup() + + +def inventory_item_view_keyboard(item_id: int, item_type: str) -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + + # Для кейсов показываем кнопку открытия вместо продажи + if item_type == "case": + kb.button(text="🎁 Открыть", callback_data=f"open_case_{item_id}") + else: + kb.button(text="💰 Продать", callback_data=f"inv_sell_{item_id}") + + kb.button(text="◀️ Назад", callback_data=f"inv_type_{item_type}") + kb.adjust(1) + return kb.as_markup() + + +def inventory_sell_back_keyboard(item_id: int) -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + kb.button(text="◀️ Назад", callback_data=f"inv_view_{item_id}") + kb.adjust(1) + return kb.as_markup() + + +def inventory_sell_confirmation_keyboard() -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + kb.button(text="✅ Подтвердить", callback_data="inv_sell_confirm") + kb.button(text="◀️ Отмена", callback_data="inv_sell_cancel") + kb.adjust(2) + return kb.as_markup() \ No newline at end of file diff --git a/src/bot/keyboards/inlines/market.py b/src/bot/keyboards/inlines/market.py new file mode 100644 index 0000000..01c3dc0 --- /dev/null +++ b/src/bot/keyboards/inlines/market.py @@ -0,0 +1,102 @@ +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from ...db.models import ItemRarityEnum + +RARITY_EMOJI = { + ItemRarityEnum.COMMON: "⬜", + ItemRarityEnum.RARE: "🟦", + ItemRarityEnum.EPIC: "🟪", + ItemRarityEnum.LEGENDARY: "🟨", +} + + +def market_main_keyboard(rarity_label: str) -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + kb.button(text="🗡️ Список оружия", callback_data="market_type_weapon") + kb.button(text="🛡️ Список брони", callback_data="market_type_armor") + kb.button(text="🎁 Кейсы", callback_data="cases_catalog") + kb.button(text="🔍 Поиск", callback_data="market_search") + kb.button(text=f"🎯 Редкость: {rarity_label}", callback_data="market_cycle_rarity") + kb.button(text="💵 Мин. цена", callback_data="market_set_min_price") + kb.button(text="💵 Макс. цена", callback_data="market_set_max_price") + kb.button(text="🧹 Сброс фильтров", callback_data="market_filters_reset") + kb.button(text="📦 Мои лоты", callback_data="market_my_listings") + kb.button(text="◀️ Назад", callback_data="mainmenu") + kb.adjust(2, 1, 2, 2, 2, 1) + return kb.as_markup() + + +def market_items_keyboard(items: list, min_prices: dict, page: int, total_pages: int, item_type_slug: str) -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + start = page * 5 + end = start + 5 + page_items = items[start:end] + + for idx, item in enumerate(page_items, start + 1): + rarity_emoji = RARITY_EMOJI.get(item.rarity, "⬜") + price = min_prices.get(item.id) + price_text = f"от {price} мон." if price is not None else "нет лотов" + kb.button( + text=f"{idx}. {rarity_emoji} {item.name} — {price_text}", + callback_data=f"market_view_item_{item.id}" + ) + + if total_pages > 0: + if page > 0: + kb.button(text="⬅️ Предыдущая", callback_data=f"market_page_{page-1}_{item_type_slug}") + if page < total_pages - 1: + kb.button(text="➡️ Следующая", callback_data=f"market_page_{page+1}_{item_type_slug}") + + kb.row( + InlineKeyboardButton(text="⚙️ Меню", callback_data="market_menu"), + InlineKeyboardButton(text="◀️ Назад", callback_data="mainmenu") + ) + kb.adjust(1, 2) + return kb.as_markup() + + +def market_item_view_keyboard(item_id: int, item_type_slug: str, min_price: int | None) -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + if min_price is not None: + kb.button(text=f"💰 Купить за {min_price}", callback_data=f"market_buy_item_{item_id}") + kb.button(text="◀️ К списку", callback_data=f"market_back_{item_type_slug}") + kb.button(text="⚙️ Меню", callback_data="market_menu") + kb.adjust(1) + return kb.as_markup() + + +def market_my_listings_keyboard(listings: list, page: int, total_pages: int) -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + start = page * 5 + end = start + 5 + page_listings = listings[start:end] + + for idx, listing in enumerate(page_listings, start + 1): + rarity_emoji = RARITY_EMOJI.get(listing.item.rarity, "⬜") + kb.button( + text=f"{idx}. {rarity_emoji} {listing.item.name} — {listing.price} мон.", + callback_data=f"market_my_view_{listing.id}" + ) + + if total_pages > 0: + if page > 0: + kb.button(text="⬅️ Предыдущая", callback_data=f"market_my_page_{page-1}") + if page < total_pages - 1: + kb.button(text="➡️ Следующая", callback_data=f"market_my_page_{page+1}") + + kb.row( + InlineKeyboardButton(text="⚙️ Меню", callback_data="market_menu"), + InlineKeyboardButton(text="◀️ Назад", callback_data="mainmenu") + ) + kb.adjust(1, 2) + return kb.as_markup() + + +def market_my_listing_view_keyboard(listing_id: int) -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + kb.button(text="🗑️ Снять с продажи", callback_data=f"market_my_remove_{listing_id}") + kb.button(text="◀️ Назад", callback_data="market_my_listings") + kb.adjust(1) + return kb.as_markup() + diff --git a/src/bot/keyboards/inlines/statistics.py b/src/bot/keyboards/inlines/statistics.py new file mode 100644 index 0000000..b4ab48c --- /dev/null +++ b/src/bot/keyboards/inlines/statistics.py @@ -0,0 +1,10 @@ +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder +from ...config import TelegramTextMap + +async def get_statistic_keyboard() -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + + kb.button(text='Назад', callback_data='mainmenu') + + return kb.adjust(1).as_markup() \ No newline at end of file diff --git a/src/bot/middlewares/logging.py b/src/bot/middlewares/logging.py new file mode 100644 index 0000000..85152c7 --- /dev/null +++ b/src/bot/middlewares/logging.py @@ -0,0 +1,32 @@ +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject +from typing import Callable, Dict, Any, Awaitable +import logging +import time + +logger = logging.getLogger(__name__) + +class LoggingMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + start_time = time.time() + + user = data.get("event_from_user") or getattr(event, "from_user", None) + user_info = f"user_id={user.id}" if user else "unknown_user" + chat_info = f"chat_id={event.chat.id if hasattr(event, 'chat') else 'none'}" + event_type = event.__class__.__name__ + + logger.debug(f"Входящее событие: {event_type} | {user_info} | {chat_info}") + + try: + result = await handler(event, data) + duration = time.time() - start_time + logger.info(f"Обработано за {duration:.3f}с | {event_type}") + return result + except Exception as e: + logger.error(f"Ошибка в обработчике {event_type}: {e}", exc_info=True) + raise \ No newline at end of file diff --git a/src/bot/middlewares/safe_edit.py b/src/bot/middlewares/safe_edit.py new file mode 100644 index 0000000..6c571b6 --- /dev/null +++ b/src/bot/middlewares/safe_edit.py @@ -0,0 +1,78 @@ +import logging +from aiogram import BaseMiddleware +from aiogram.types import Message +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError + +class SafePatchMiddleware(BaseMiddleware): + patched = False + cache = {} + + async def __call__(self, handler, event, data): + if not SafePatchMiddleware.patched: + self.apply_patches(data["bot"]) + SafePatchMiddleware.patched = True + + return await handler(event, data) + + def apply_patches(self, bot): + original_edit_text = Message.edit_text + original_bot_edit = bot.edit_message_text + + async def safe_edit_text(msg: Message, *args, **kwargs): + new_text = args[0] if args else kwargs.get("text") + key = (msg.chat.id, msg.message_id) + + if isinstance(new_text, str) and SafePatchMiddleware.cache.get(key) == new_text: + return None + + try: + result = await original_edit_text(msg, *args, **kwargs) + if isinstance(new_text, str): + SafePatchMiddleware.cache[key] = new_text + return result + + except TelegramBadRequest as e: + if "message is not modified" in str(e).lower(): + return None + if "can't" in str(e).lower() or "not found" in str(e).lower(): + return None + raise + + except TelegramForbiddenError: + return None + + except Exception as e: + logging.error(f"safe_edit_text unexpected: {e}") + return None + + async def safe_bot_edit(bot, chat_id=None, message_id=None, *args, **kwargs): + new_text = kwargs.get("text") or (args[2] if len(args) > 2 else None) + key = (chat_id, message_id) + + if isinstance(new_text, str) and SafePatchMiddleware.cache.get(key) == new_text: + return None + + try: + result = await original_bot_edit(bot, chat_id, message_id, *args, **kwargs) + if isinstance(new_text, str): + SafePatchMiddleware.cache[key] = new_text + return result + + except TelegramBadRequest as e: + if "message is not modified" in str(e).lower(): + return None + if "can't" in str(e).lower() or "not found" in str(e).lower(): + return None + return None + + except TelegramForbiddenError: + return None + + except Exception as e: + logging.error(f"safe_bot_edit unexpected: {e}") + return None + + Message.edit_text = safe_edit_text + bot.edit_message_text = safe_bot_edit + + logging.info("SafePatchMiddleware: monkey patch applied") \ No newline at end of file diff --git a/src/bot/services/cache.py b/src/bot/services/cache.py new file mode 100644 index 0000000..4e18f8e --- /dev/null +++ b/src/bot/services/cache.py @@ -0,0 +1,33 @@ +import asyncio +import time +import json +from typing import Optional, Dict, Any, Callable, Tuple +from functools import wraps +from redis.asyncio import Redis +from tortoise import Tortoise +import logging +from dataclasses import dataclass +from enum import Enum + +logging = logging.getLogger(__name__) + +class CacheMode(Enum): + LAZY = 'lazy' + ACTIVE = 'active' + OFF = 'off' + +@dataclass +class CacheStats: + hits: int = 0 + misses: int = 0 + invalidations: int = 0 + stale_updates: int = 0 + +class CacheService: + def __init__(self): + self.redis_client: Optional[Redis] = None + self.cache_ttls = { + 'user': 300, + 'items': 600, + 'market_prices': 60, + } \ No newline at end of file diff --git a/src/bot/services/case.py b/src/bot/services/case.py new file mode 100644 index 0000000..cda0769 --- /dev/null +++ b/src/bot/services/case.py @@ -0,0 +1,184 @@ +"""Сервис управления кейсами. + +Функциональность: +- Открытие кейсов и получение предмета из коллекции +- Получение информации о кейсе и его содержимом +- Проверка лимита открытий для ограниченных кейсов +""" +import random +from typing import Optional, List, Dict, Any, Tuple +from tortoise.exceptions import DoesNotExist +from tortoise.transactions import in_transaction + +from ...bot.db.models import Items, Users, InventoryItems, ItemTypeEnum, ItemRarityEnum +from .inventory import InventoryService +from .item import ItemService, SCHEMAS_MAP + + +class CaseService: + """Сервис для управления кейсами и их открытием.""" + + def __init__(self) -> None: + self.inventory_service = InventoryService() + self.item_service = ItemService(SCHEMAS_MAP) + + async def get_case(self, case_id: int) -> Optional[Items]: + """Получает кейс по ID с проверкой типа. + + Args: + case_id (int): ID кейса + + Returns: + Optional[Items]: объект кейса или None + """ + return await Items.get_or_none(id=case_id, type=ItemTypeEnum.CASE) + + async def get_all_cases(self) -> List[Items]: + """Получает все доступные кейсы. + + Returns: + List[Items]: список всех кейсов + """ + return await Items.filter(type=ItemTypeEnum.CASE).order_by("rarity", "name").all() + + async def get_case_by_name(self, name: str) -> Optional[Items]: + """Получает кейс по названию. + + Args: + name (str): название кейса + + Returns: + Optional[Items]: объект кейса или None + """ + return await Items.get_or_none(name__iexact=name, type=ItemTypeEnum.CASE) + + async def get_case_info(self, case: Items) -> Dict[str, Any]: + """Получает полную информацию о кейсе. + + Args: + case (Items): объект кейса + + Returns: + Dict[str, Any]: словарь с информацией о кейсе + """ + case_attrs = case.attributes + + return { + "id": case.id, + "name": case.name, + "description": case.description, + "rarity": ItemRarityEnum(case.rarity).name, + "collection": case_attrs.get("collection", "Unknown"), + "is_limited": case_attrs.get("is_limited", False), + "max_opens": case_attrs.get("max_opens"), + "items_in_case": len(case_attrs.get("storage", [])), + } + + async def open_case(self, user: Users, case: Items) -> Tuple[Items, str]: + """Открывает кейс и выдает случайный предмет из коллекции. + + АТОМАРНАЯ ОПЕРАЦИЯ: все шаги выполняются в транзакции для гарантии целостности. + Гарантирует, что при нажатии кнопки 10 раз подряд предмет выдается РОВНО 10 раз. + + Алгоритм: + 1. Проверяет наличие кейса в инвентаре (количество > 0) + 2. Получает список предметов из коллекции + 3. Выбирает случайный предмет по весам вероятности + 4. В транзакции: добавляет предмет И удаляет кейс + + Args: + user (Users): игрок, открывающий кейс + case (Items): кейс для открытия + + Returns: + Tuple[Items, str]: (полученный предмет, сообщение о результате) + + Raises: + ValueError: если у игрока нет кейса или хранилище пусто + """ + # Получаем коллекцию предметов ПЕРЕД транзакцией + storage = case.attributes.get("storage", []) + if not storage: + raise ValueError(f"Кейс '{case.name}' пуст (нет предметов в коллекции)") + + # Выбираем случайный предмет ДО транзакции + reward_item = self._select_weighted_item(storage) + reward_item_id = reward_item["item_id"] + + # Получаем предмет из БД + item = await Items.get_or_none(id=reward_item_id) + if not item: + raise ValueError(f"Предмет с ID {reward_item_id} не найден в БД") + + # АТОМАРНАЯ ТРАНЗАКЦИЯ + async with in_transaction(): + # Пересчитываем и проверяем кейс в инвентаре (блокируется БД) + case_inv = await InventoryItems.filter(user=user, item=case).first() + + if not case_inv or case_inv.quantity < 1: + raise ValueError(f"У вас нет кейса '{case.name}' в инвентаре") + + # Добавляем предмет в инвентарь + await self.inventory_service.add(user, item, quantity=1) + + # Уменьшаем количество кейсов + case_inv.quantity -= 1 + await case_inv.save() + + # Если количество == 0, удаляем запись + if case_inv.quantity <= 0: + await case_inv.delete() + + rarity_name = ItemRarityEnum(item.rarity).name + return item, f"✨ Вы получили: **{item.name}** ({rarity_name})" + + def _select_weighted_item(self, storage: List[Dict[str, Any]]) -> Dict[str, Any]: + """Выбирает случайный предмет из коллекции по весам вероятности. + + Алгоритм использует взвешенный выбор, где каждый предмет имеет + вероятность выпадения (weight). Большее значение = выше вероятность. + + Args: + storage (List[Dict[str, Any]]): список предметов с весами + Формат: [ + {"item_id": 1, "weight": 0.5}, + {"item_id": 2, "weight": 0.3}, + {"item_id": 3, "weight": 0.2} + ] + + Returns: + Dict[str, Any]: выбранный предмет из коллекции + """ + items = [item for item in storage] + weights = [item.get("weight", 1.0) for item in items] + + return random.choices(items, weights=weights, k=1)[0] + + async def get_cases_by_rarity(self, rarity: ItemRarityEnum) -> List[Items]: + """Получает все кейсы определенной редкости. + + Args: + rarity (ItemRarityEnum): редкость кейса + + Returns: + List[Items]: список кейсов нужной редкости + """ + return await Items.filter( + type=ItemTypeEnum.CASE, + rarity=rarity + ).order_by("name").all() + + async def get_cases_by_collection(self, collection: str) -> List[Items]: + """Получает все кейсы определенной коллекции. + + Args: + collection (str): название коллекции + + Returns: + List[Items]: список кейсов в коллекции + """ + all_cases = await self.get_all_cases() + return [ + case for case in all_cases + if case.attributes.get("collection", "").lower() == collection.lower() + ] diff --git a/src/bot/services/enemy.py b/src/bot/services/enemy.py new file mode 100644 index 0000000..28be5cc --- /dev/null +++ b/src/bot/services/enemy.py @@ -0,0 +1,168 @@ +"""Сервис управления врагами и боевой логикой. + +Обрабатывает спавн врагов по уровню, расчёт наград и выпадение лута. +Интегрирует калькулятор боевых характеристик для расчёта HP и урона врага. +""" +from .inventory import InventoryService +from ..db.models import Enemies, Items, Users, EnemyTypeEnum, Locations +from typing import List, Dict +from ..game.logic.calculation import EnemyCalculator +from random import choice, random +from math import floor + +# TODO : Перепроверить все +class EnemyService(): + """Сервис для управления врагами и боевыми наградами. + + Функциональность: + - Выбор врага по уровню игрока и локации + - Расчёт статистики врага (HP, урон) + - Выдача наград (монеты, опыт, лут) + - Система уровней врагов (COMMON, ELITE, BOSS) + """ + def __init__(self) -> None: + self.calc = EnemyCalculator() + self.inventory = InventoryService() + + async def spawn(self, user: Users, location: Locations) -> Enemies: + """Выбирает случайного врага для боя в локации. + + Алгоритм: + 1. Определяет тип врага (COMMON/ELITE/BOSS) на основе уровня + 2. Ищет врагов этого типа в данной локации + 3. Если нет → ищет врагов типа в любой локации + 4. Выбирает случайного из найденных + + Args: + user (Users): игрок (уровень влияет на тип врага) + location (Locations): локация для боя + + Returns: + Enemies: выбранный враг + + Raises: + ValueError: если нет доступных врагов + """ + level = max(1, user.lvl) + rarity = self._get_rarity(level) + candidates = await Enemies.filter( + type=rarity, + is_active=True, + locations__id=location.id + ).all() + + if not candidates: + candidates = await Enemies.filter(type=rarity, is_active=True).all() + if not candidates: + raise ValueError("No enemies available for this level") + return choice(candidates) + + async def get_battle_stats(self, enemy: Enemies, user: Users) -> Dict: + return await self.calc.get_stats(enemy, user) # await + + async def give_reward(self, user: Users, enemy: Enemies, exp: int, coins: int) -> Dict: + """Выдаёт награды за победу над врагом. + + Процесс: + 1. Добавляет монеты и опыт к игроку + 2. Проверяет повышение уровня (требуется 65*(level+1) опыта) + 3. Разыгрывает лут (drop_items) с шансом дропа + 4. Добавляет выпавший лут в инвентарь + 5. Сохраняет изменения в БД + + Args: + user (Users): игрок, получающий награду + enemy (Enemies): враг для расчёта лута + exp (int): количество опыта + coins (int): количество монет + + Returns: + Dict: результат с ключами: + - 'coin', 'exp': выданные награды + - 'level_up': True если повышен уровень + - 'drop': список выпавшего лута ['name x qty', ...] + """ + user.coins += coins + user.exp += exp + leveled_up = await self._level_up(user) + drops = await self._roll_drops(enemy) + for drop in drops: + await self.inventory.add(user, drop['item'], drop['quantity']) + await user.save() + return { + "coin": coins, "exp": exp, + 'level_up': leveled_up, 'drop': [f"{d['item'].name} x{d['quantity']}" for d in drops] + } + + def _get_rarity(self, level: int) -> EnemyTypeEnum: + """Определяет тип врага на основе уровня игрока (взвешенная вероятность). + + Уровень < 8: + 100% COMMON + + Уровень 8-19: + 20% ELITE (2 из 10) + 80% COMMON (8 из 10) + + Уровень ≥ 20: + 40% BOSS (1 из 2.5) + 30% ELITE (3 из 10) + 30% COMMON (4 из 10) + + Args: + level (int): уровень игрока + + Returns: + EnemyTypeEnum: тип врага (COMMON, ELITE, BOSS) + """ + if level >= 20: + return choice([EnemyTypeEnum.BOSS] + [EnemyTypeEnum.ELITE] * 3 + [EnemyTypeEnum.COMMON] * 4) + elif level >= 8: + return choice([EnemyTypeEnum.ELITE] * 2 + [EnemyTypeEnum.COMMON] * 8) + return EnemyTypeEnum.COMMON + + async def _roll_drops(self, enemy: Enemies) -> List[Dict]: + """Разыгрывает выпадение лута от врага. + + Алгоритм: + 1. Рассчитывает шанс дропа (базовый + бонус от уровня) + 2. Проверяет вероятность: если не выпадает → возвращает [] + 3. Перебирает drop_items врага; каждый с вероятностью 75% добавляется в лут + + Args: + enemy (Enemies): враг + + Returns: + List[Dict]: список лута, каждый элемент: + {'item': Items, 'quantity': 1} + """ + chance = await self.calc.get_drop_chance(enemy, 1) # player_level TODO: from user + if random() * 100 > chance: + return [] + drops = [] + drop_items = await enemy.drop_items.all() + for item in drop_items: + if random() < 0.75: + drops.append({"item": item, "quantity": 1}) + return drops + + async def _level_up(self, user: Users) -> bool: + """Проверяет и применяет повышение уровня. + + Требуемый опыт для уровня N: 65 * (N + 1). + Пока опыт >= требуемого → повышает уровень, вычитает необходимый опыт. + + Args: + user (Users): игрок + + Returns: + bool: True если уровень повысился, иначе False + """ + old_lvl = user.lvl + required_exp = 65 * (user.lvl + 1) + while user.exp >= required_exp: + user.lvl += 1 + user.exp -= required_exp + required_exp = 65 * (user.lvl + 1) + + return user.lvl > old_lvl \ No newline at end of file diff --git a/src/bot/services/inventory.py b/src/bot/services/inventory.py new file mode 100644 index 0000000..be82d99 --- /dev/null +++ b/src/bot/services/inventory.py @@ -0,0 +1,182 @@ +"""Сервис управления инвентарём игрока. + +Обрабатывает добавление, удаление, экипирование/разоружение предметов. +Гарантирует, что одновременно экипирован только один предмет каждого типа. +""" +from typing import Optional, List +from tortoise.exceptions import DoesNotExist +from ...bot.db.models import InventoryItems, Users, Items, ItemTypeEnum +from ...bot.services.item import ItemService, SCHEMAS_MAP + +# TODO: Сделать методы для получения используемых предметов, сделать копии методов для получения (dict), также улучшить все существующие методы +# TODO: Сделать систему при который можно одеть только один предмет каждого типа +class InventoryService(): + """Сервис для управления инвентарём игрока. + + Функциональность: + - Добавление/удаление предметов с проверкой количества + - Экипирование/разоружение с гарантией одного предмета на тип + - Получение списка предметов и словаря инвентаря + - Поиск экипированного предмета по типу + """ + def __init__(self) -> None: + self.item_service = ItemService(SCHEMAS_MAP) + + # ? --- CRUD методы --- + async def add(self, user: Users, item: Items, quantity: int = 1, auto_create: bool = True) -> InventoryItems: + """Добавляет предмет в инвентарь игрока или увеличивает количество. + + Если предмет уже в инвентаре → увеличивает quantity. + Если предмета нет → создаёт новую запись (если auto_create=True). + + Args: + user (Users): игрок, владелец инвентаря + item (Items): предмет для добавления + quantity (int): количество добавляемых копий (по умолчанию 1) + auto_create (bool): создать новую запись, если предмета нет + + Returns: + InventoryItems: обновленная или новая запись инвентаря + + Raises: + ValueError: если предмета нет и auto_create=False + """ + try: + inv = await InventoryItems.get(user=user, item=item) + inv.quantity += quantity + await inv.save() + return inv + + except DoesNotExist: + if not auto_create: + raise ValueError("Item not found in inventory and auto_create=False") + + inv = await InventoryItems.create(user=user, item=item, quantity=quantity) + return inv + + async def remove(self, user: Users, item: Items, quantity: int = 1) -> None: + """Удаляет предмет из инвентаря игрока. + + Уменьшает количество на quantity. Если quantity = 0 → удаляет запись полностью. + + Args: + user (Users): владелец инвентаря + item (Items): предмет для удаления + quantity (int): количество копий для удаления + + Raises: + ValueError: если предмета нет или недостаточно копий + """ + inv = await InventoryItems.get_or_none(user=user, item=item) + if not inv: + raise ValueError("Item not found in inventory") + + if inv.quantity < quantity: + raise ValueError("Not enough items to remove") + + inv.quantity -= quantity + + if inv.quantity == 0: + await inv.delete() + else: + await inv.save() + + async def get(self, user: Users) -> Optional[List[InventoryItems]]: + inv = await InventoryItems.filter(user=user).prefetch_related('item') + return inv + + async def get_inventory_dict(self, user: Users) -> dict: + inv = await InventoryItems.filter(user=user).prefetch_related('item') + return {str(i.item.name): i.quantity for i in inv} + + + async def clear(self, user: Users) -> None: + await InventoryItems.filter(user=user).delete() + + async def equip_item(self, user: Users, item: Items) -> InventoryItems: + """Экипирует предмет (отмечает его как используемый). + + Проверяет: + 1. Предмет есть в инвентаре + 2. Предмет не экипирован (уникальность: один на тип) + 3. Нет другого экипированного предмета того же типа + + Если проверки пройдены → устанавливает equipped=True. + + Args: + user (Users): владелец инвентаря + item (Items): предмет для экипирования + + Returns: + InventoryItems: экипированная запись инвентаря + + Raises: + ValueError: если предмета нет, уже экипирован или занято место типа + """ + inv = await InventoryItems.get_or_none(user=user, item=item) + if not inv: + raise ValueError("Item not found in inventory") + + if inv.equipped: + raise ValueError("Item is already equipped") + + equipped_same_type = await InventoryItems.filter( + user=user, + equipped=True, + item__type=item.type + ).first() + + if equipped_same_type: + raise ValueError(f"You already have an equipped item of type {item.type}") + + inv.equipped = True + await inv.save() + return inv + + async def unequip_item(self, user: Users, item: Items) -> None: + """Снимает экипировку с предмета. + + Проверяет: + 1. Предмет есть в инвентаре + 2. Предмет экипирован + + Если проверки пройдены → устанавливает equipped=False. + + Args: + user (Users): владелец инвентаря + item (Items): предмет для разоружения + + Raises: + ValueError: если предмета нет или он не экипирован + """ + inv = await InventoryItems.get_or_none(user=user, item=item) + if not inv: + raise ValueError("Item not found in inventory") + + if not inv.equipped: + raise ValueError("Item is not equipped") + + inv.equipped = False + await inv.save() + + async def get_equipped_item(self, user: Users, item_type: ItemTypeEnum) -> Optional[InventoryItems]: + """Получает экипированный предмет заданного типа. + + Args: + user (Users): владелец инвентаря + item_type (ItemTypeEnum): тип предмета (WEAPON, ARMOR и т.д.) + + Returns: + Optional[InventoryItems]: запись инвентаря с экипированным предметом или None + """ + return await InventoryItems.filter( + user=user, + equipped=True, + item__type=item_type + ).prefetch_related("item").first() + + async def get_inventory_item(self, user: Users, inventory_item_id: int) -> Optional[InventoryItems]: + return await InventoryItems.filter( + user=user, + id=inventory_item_id + ).prefetch_related("item").first() \ No newline at end of file diff --git a/src/bot/services/item.py b/src/bot/services/item.py new file mode 100644 index 0000000..5ad7001 --- /dev/null +++ b/src/bot/services/item.py @@ -0,0 +1,121 @@ +"""Сервис управления предметами в системе. + +Обрабатывает создание, валидацию и CRUD операции над предметами. +Использует Pydantic-схемы для валидации атрибутов каждого типа предмета. +""" +from typing import Optional, Type, List, Dict, Union + +from ...bot.db.models import Items, ItemTypeEnum, ItemRarityEnum +from ...bot.db.schemas.items import ArmorAttributes, BaseAttributes, WeaponAttributes +from pydantic import ValidationError + +SCHEMAS_MAP = { + ItemTypeEnum.WEAPON: WeaponAttributes, + ItemTypeEnum.ARMOR: ArmorAttributes, +} + + +class ItemService: + """Сервис управления предметами. + + Валидирует атрибуты через Pydantic-схемы и обеспечивает типобезопасность. + Поддерживает расширяемость: новые типы предметов добавляются в SCHEMAS_MAP. + """ + def __init__(self, schemas_map: Optional[Dict[ItemTypeEnum, Type[BaseAttributes]]] = None): + self.schemas_map = schemas_map or SCHEMAS_MAP + + def _get_schema_class(self, item_type: Union[ItemTypeEnum, int]) -> Type[BaseAttributes]: + # Accept either ItemTypeEnum or its int value + if isinstance(item_type, int): + item_type = ItemTypeEnum(item_type) + + schema_cls = self.schemas_map.get(item_type) + if not schema_cls: + raise ValueError(f"Unsupported item type: {item_type}") + return schema_cls + + def _validate_attributes(self, item_type: Union[ItemTypeEnum, int], attributes: dict) -> dict: + schema_cls = self._get_schema_class(item_type) + try: + validated = schema_cls(**attributes) + return validated.dict() + + except ValidationError as _ex: + raise ValueError(f'Invalid attributes for {item_type}: {_ex.errors()}') + + async def create(self, name: str, item_type: ItemTypeEnum, item_rarity: ItemRarityEnum, attributes: dict) -> Items: + """Создаёт новый предмет или возвращает существующий. + + Валидирует атрибуты через соответствующую Pydantic-схему. + Использует get_or_create → если предмет с таким name существует, возвращает его. + + Args: + name (str): имя предмета + item_type (ItemTypeEnum): тип (WEAPON, ARMOR и т.д.) + item_rarity (ItemRarityEnum): редкость (COMMON, RARE, EPIC, LEGENDARY) + attributes (dict): словарь атрибутов (валидируется схемой) + + Returns: + Items: созданный или найденный предмет + + Raises: + ValueError: если атрибуты не проходят валидацию + """ + valid_attrs = self._validate_attributes(item_type, attributes) + + item, _created = await Items.get_or_create(name=name, defaults={ + "rarity": item_rarity, + "type": item_type, + "attributes": valid_attrs, + }) + + return item + + async def get_by_id(self, item_id: int) -> Optional[Items]: + item = await Items.get_or_none(id=item_id) + return item + + async def get_by_name(self, item_name: str) -> Optional[List[Items]]: + items = await Items.filter(name=item_name).all() + return items + + async def get_by_type(self, item_type: ItemTypeEnum) -> Optional[List[Items]]: + items = await Items.filter(type=item_type).all() + return items + + async def update(self, item_id: int, **updates: any) -> Items: + """Обновляет предмет (атрибуты, имя, редкость). + + Если изменяются атрибуты → валидирует через текущую схему типа предмета. + + Args: + item_id (int): ID предмета + **updates: ключи 'name', 'rarity', 'attributes' + + Returns: + Items: обновленный предмет + + Raises: + ValueError: если предмет не найден или атрибуты невалидны + """ + item = await self.get_by_id(item_id) + if not item: + raise ValueError("Item not found") + + if "attributes" in updates: + item.attributes = self._validate_attributes(item.type, updates["attributes"]) + + if "name" in updates: + item.name = updates["name"] + + if "rarity" in updates: + item.rarity = updates["rarity"] + + await item.save() + return item + + async def delete(self, item_id: int): + item = await self.get_by_id(item_id) + if not item: + raise ValueError("Item not found") + await item.delete() \ No newline at end of file diff --git a/src/bot/services/market.py b/src/bot/services/market.py new file mode 100644 index 0000000..061039b --- /dev/null +++ b/src/bot/services/market.py @@ -0,0 +1,82 @@ +from typing import Dict, List, Optional + +from tortoise.functions import Min +from tortoise.transactions import in_transaction + +from ...bot.db.models import Items, MarketItem, Users, ItemTypeEnum, ItemRarityEnum +from .inventory import InventoryService + + +class MarketService: + def __init__(self, inventory_service: Optional[InventoryService] = None) -> None: + self.inventory_service = inventory_service or InventoryService() + + async def get_min_price(self, item: Items) -> Optional[int]: + record = await MarketItem.filter(item=item).order_by("price").first() + return record.price if record else None + + async def get_min_price_map(self, item_ids: List[int]) -> Dict[int, int]: + if not item_ids: + return {} + + rows = await MarketItem.filter(item_id__in=item_ids)\ + .annotate(min_price=Min("price"))\ + .group_by("item_id")\ + .values("item_id", "min_price") + + return {row["item_id"]: row["min_price"] for row in rows} + + async def list_item(self, item: Items, seller: Users, price: int) -> MarketItem: + return await MarketItem.create(item=item, seller=seller, price=price) + + async def get_items( + self, + item_type: ItemTypeEnum, + rarity: Optional[ItemRarityEnum] = None, + search: Optional[str] = None + ) -> List[Items]: + qs = Items.filter(type=item_type) + if rarity and rarity != ItemRarityEnum.COMMON: # Avoid filtering if 'all' selected + qs = qs.filter(rarity=rarity) + if search and search.strip(): + qs = qs.filter(name__icontains=search) + + return await qs.order_by("rarity", "name").all() + + async def get_player_listings(self, user: Users) -> List[MarketItem]: + return await MarketItem.filter(seller=user).prefetch_related("item").order_by("price", "id") + + async def remove_listing(self, listing_id: int, user: Users) -> bool: + listing = await MarketItem.get_or_none(id=listing_id, seller=user) + if not listing: + return False + await listing.delete() + return True + + async def buy_cheapest_listing(self, item: Items, buyer: Users) -> int: + listing = await MarketItem.filter(item=item).select_related("seller", "item").order_by("price", "id").first() + if not listing: + raise ValueError("Нет доступных лотов.") + + await buyer.refresh_from_db() + if listing.seller_id == buyer.id: + raise ValueError("Нельзя купить собственный лот.") + + if buyer.coins < listing.price: + raise ValueError("Недостаточно монет.") + + seller = listing.seller + await seller.refresh_from_db() + + async with in_transaction(): + buyer.coins -= listing.price + await buyer.save() + + seller.coins += listing.price + await seller.save() + + await self.inventory_service.add(buyer, listing.item) + await listing.delete() + + return listing.price + diff --git a/src/bot/services/user.py b/src/bot/services/user.py new file mode 100644 index 0000000..3e2266f --- /dev/null +++ b/src/bot/services/user.py @@ -0,0 +1,48 @@ +from ..db.models import Users, ItemRarityEnum, Items +from tortoise.exceptions import DoesNotExist +from .inventory import InventoryService +from .item import ItemService, SCHEMAS_MAP + +# TODO : Создать UserService, сделать выдачу стартового набор, сделать формулу для расчета EXP-LVL, сделать метод на добавление XP +class UserService(): + def __init__(self) -> None: + self.inventory_service = InventoryService() + self.item_service = ItemService(SCHEMAS_MAP) + + async def get_by_telegram_id(self, id: int) -> Users: + user = await Users.get_or_none(telegram_id=id) + return user + + async def create(self, telegram_id: int, username: str = '', first_name: str = '', last_name: str = '', coins: int = 150, exp: int = 0, lvl: int = 0) -> Users: + user, created = await Users.get_or_create( + telegram_id=telegram_id, + defaults={ + "username": username, + "first_name": first_name, + "last_name": last_name, + "coins": coins, + "exp": exp, + "lvl": lvl, + } + ) + + if created: + await self.inventory_service.add(user, await self.item_service.get_by_id(1)) + await self.inventory_service.add(user, await self.item_service.get_by_id(2)) + await self.inventory_service.equip_item(user, await self.item_service.get_by_id(1)) + await self.inventory_service.equip_item(user, await self.item_service.get_by_id(2)) + + return user + + async def add_exp(self, user: Users, amount: int): + user.exp += amount + + old_lvl = user.lvl + required_exp = 65 * (user.lvl + 1) + while user.exp >= required_exp: + user.lvl += 1 + user.exp -= required_exp + required_exp = 65 * (user.lvl + 1) + + return {"exp": user.exp, "lvl": user.lvl} + \ No newline at end of file