From 2e919e884069773d2bf4c9dafa49760b5b7619d7 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Tue, 28 Oct 2025 22:41:13 +0500 Subject: [PATCH 01/28] Implement initial bot structure with database integration and command handling --- main.py | 14 ++++++++++++++ scripts/migrate.py | 0 src/bot/bot.py | 16 ++++++++++++++++ src/bot/config.py | 10 ++++++++++ src/bot/db/database.py | 29 +++++++++++++++++++++++++++++ src/bot/db/models.py | 17 +++++++++++++++++ src/bot/handlers/default.py | 9 +++++++++ 7 files changed, 95 insertions(+) create mode 100644 scripts/migrate.py create mode 100644 src/bot/bot.py create mode 100644 src/bot/config.py create mode 100644 src/bot/db/database.py create mode 100644 src/bot/db/models.py create mode 100644 src/bot/handlers/default.py diff --git a/main.py b/main.py index e69de29..b63778a 100644 --- a/main.py +++ b/main.py @@ -0,0 +1,14 @@ +from src.bot.bot import bootstrap +from src.bot.db.database import init_db, close_db +import asyncio + + +async def main() -> None: + await init_db() + try: + await bootstrap() + finally: + await close_db() + +if __name__ == '__main__': + asyncio.run(main()) \ No newline at end of file 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..9aa8439 --- /dev/null +++ b/src/bot/bot.py @@ -0,0 +1,16 @@ +from aiogram import Bot, Dispatcher +from .config import ConfigService + + +if not ConfigService: + raise Exception("You should enter `BOT_API_KEY` in .env") + +bot = Bot(token=ConfigService.BOT_API_KEY) + +async def bootstrap() -> None: + dp = Dispatcher() + + dp.include_routers() + + await dp.start_polling(bot) + diff --git a/src/bot/config.py b/src/bot/config.py new file mode 100644 index 0000000..452fdfc --- /dev/null +++ b/src/bot/config.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from os import getenv +from dotenv import load_dotenv + +load_dotenv() + +@dataclass +class ConfigService: + BOT_API_KEY = getenv("BOT_API_KEY") + DATABASE_URL = getenv("DATABASE_URL") \ 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..64b2c74 --- /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 = { + "connection": {"default": ConfigService.DATABASE_URL}, + "apps": { + "models": { + "models": ["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..73dba8b --- /dev/null +++ b/src/bot/db/models.py @@ -0,0 +1,17 @@ +from enum import unique +from tortoise import fields +from tortoise.models import Model + + +class User(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) + + created_at = fields.DatetimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"" \ 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..c7e744d --- /dev/null +++ b/src/bot/handlers/default.py @@ -0,0 +1,9 @@ +from aiogram import Router, F +from aiogram.filters import Command, CommandStart +from aiogram.types import Message, Callback + +default_router = Router("default_router") + +@default_router.message(CommandStart()) +async def start_commands(message: Message) -> None: + await message.reply(text=f'') From ade9c5b90a9c20f3dc0a51474026c7b898abfca5 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Wed, 29 Oct 2025 20:09:59 +0500 Subject: [PATCH 02/28] Add item and inventory models with enums for item type and rarity --- src/bot/db/models.py | 40 +++++++++++++++++++- src/bot/db/schemas/items.py | 14 +++++++ src/bot/services/item_service.py | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/bot/db/schemas/items.py create mode 100644 src/bot/services/item_service.py diff --git a/src/bot/db/models.py b/src/bot/db/models.py index 73dba8b..2a3b18c 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -1,6 +1,17 @@ +from email.policy import default from enum import unique +import re +from statistics import quantiles from tortoise import fields from tortoise.models import Model +from enum import Enum + +class ItemTypeEnum(Enum): + WEAPON = 'weapon' + ARMOR = 'armor' + +class ItemRarityEnum(Enum): + COMMON = 'common' class User(Model): @@ -14,4 +25,31 @@ class User(Model): created_at = fields.DatetimeField(auto_now_add=True) def __str__(self) -> str: - return f"" \ No newline at end of file + return f"" + +class Item(Model): + id = fields.BigIntField(pk=True) + + name = fields.CharField(max_length=225, null=False) + description = fields.CharField(max_length=512, null=True) + type = fields.CharEnumField(ItemTypeEnum) + attributes = fields.JSONField(default=dict) + rarity = fields.CharEnumField(ItemRarityEnum, default=ItemRarityEnum.COMMON) + + created_at = fields.DatetimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"" + +class InventoryItem(Model): + id = fields.BigIntField(pk=True) + + user = fields.ForeignKeyField('models.User', related_name="inventory_items") + item = fields.ForeignKeyField('models.item', related_name='instances') + quantity = fields.IntField(default=1) + equipped = fields.BooleanField(default=True) + + acquired_at = fields.DatetimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"" diff --git a/src/bot/db/schemas/items.py b/src/bot/db/schemas/items.py new file mode 100644 index 0000000..92b8c6a --- /dev/null +++ b/src/bot/db/schemas/items.py @@ -0,0 +1,14 @@ +from logging import critical +from pydantic import BaseModel, Field +from typing import Optional + + +class BaseAttributes(BaseModel): + pass + +class WeaponAttributes(BaseAttributes): + damage: int = Field(..., ge=1) + critical_chance: float = Field(default= 0.0, ge=0.0, le=1.0) + +class ArmorAttributes(BaseAttributes): + pass diff --git a/src/bot/services/item_service.py b/src/bot/services/item_service.py new file mode 100644 index 0000000..9f94361 --- /dev/null +++ b/src/bot/services/item_service.py @@ -0,0 +1,65 @@ +from ast import Dict +from typing import Optional, Type + +from tortoise.exceptions import DoesNotExist +from bot.db.models import Item, InventoryItem, ItemTypeEnum, ItemEnum, ItemRarityEnum +from bot.db.schemas.items import ArmorAttributes, BaseAttributes, WeaponAttributes +from pydantic import ValidationError + +SCHEMAS_MAP = { + ItemTypeEnum.WEAPON: WeaponAttributes, + ItemTypeEnum.ARMOR: ArmorAttributes +} + +class ItemService(): + def __init__(self, schemas_map: Optional[Dict[str, Type[BaseAttributes]]]): + self.schemas_map = schemas_map or SCHEMAS_MAP + + def _get_schema_class(self, item_type: str) -> Type[BaseAttributes]: + 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: str, 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): + valid_attrs = self._validate_attributes(item_type, attributes) + + item = await Item.get_or_create(name=name, rarity=item_rarity, type=item_type, attributes=valid_attrs) + return item + + async def get_by_id(self, item_id: int) -> Item: + item = await Item.get_or_none(id=item_id) + return item + + async def get_by_name(self, item_name: str) -> Optional[Dict[Item]]: + items = await Item.get(name=item_name) + return items + + async def update(self, item_id: str, **updates: any) -> Item: + item = await self.get_item(item_id) + + 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): + item = await self.get_by_id(item_id) + await item.delete() \ No newline at end of file From 02eba6dea9e0ca84cff2e1298174725602764e20 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Wed, 29 Oct 2025 22:18:41 +0500 Subject: [PATCH 03/28] Rename ItemService file and related item handling logic from the bot services --- src/bot/services/inventory.py | 65 +++++++++++++++++++ src/bot/services/{item_service.py => item.py} | 5 +- 2 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/bot/services/inventory.py rename src/bot/services/{item_service.py => item.py} (94%) diff --git a/src/bot/services/inventory.py b/src/bot/services/inventory.py new file mode 100644 index 0000000..0a0ee20 --- /dev/null +++ b/src/bot/services/inventory.py @@ -0,0 +1,65 @@ +from typing import Optional, List +from tortoise.exceptions import DoesNotExist, IntegrityError +from bot.db.models import Inventory, User, Item +from bot.services.item import ItemService + + +class InventoryService(): + def __init__(self) -> None: + self.item_service = ItemService() + + + async def add(self, user: User, item: Item, quantity: int = 1, auto_create: bool = True) -> Inventory: + try: + inv = await Inventory.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 Inventory.create(user=user, item=item, quantity=quantity) + return inv + + async def remove(self, user: User, item: Item, quantity: int = 1) -> Inventory: + inv = await Inventory.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: User) -> List[Inventory]: + inv = await Inventory.filter(user=user).prefetch_related('item') + return inv + + async def clear(self, user: User) -> None: + await Inventory.filter(user=user).delete() + + async def equip_item(self, user: User, item: Item) -> Inventory: + inv = await Inventory.get_or_none(user=user, item=item) + if not inv: + raise ValueError("Item not found in inventory") + + inv.equipped = True + await inv.save() + + return inv + + async def unequip_item(self, user: User, item: Item) -> Inventory: + inv = await Inventory.get_or_none(user=user, item=item) + if not inv: + raise ValueError("Item not found in inventory") + + inv.equipped = False + await inv.save() + return inv diff --git a/src/bot/services/item_service.py b/src/bot/services/item.py similarity index 94% rename from src/bot/services/item_service.py rename to src/bot/services/item.py index 9f94361..ec3107f 100644 --- a/src/bot/services/item_service.py +++ b/src/bot/services/item.py @@ -1,5 +1,4 @@ -from ast import Dict -from typing import Optional, Type +from typing import Optional, Type, List, Dict from tortoise.exceptions import DoesNotExist from bot.db.models import Item, InventoryItem, ItemTypeEnum, ItemEnum, ItemRarityEnum @@ -41,7 +40,7 @@ async def get_by_id(self, item_id: int) -> Item: item = await Item.get_or_none(id=item_id) return item - async def get_by_name(self, item_name: str) -> Optional[Dict[Item]]: + async def get_by_name(self, item_name: str) -> Optional[List[Item]]: items = await Item.get(name=item_name) return items From 3c834680b6470b8a3b342599bd73213baae78795 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Wed, 29 Oct 2025 22:36:31 +0500 Subject: [PATCH 04/28] Add default command handling and greeting responses to the bot. Introduce TelegramTextMap for text responses and implement a new Enemy model in the database. --- src/bot/bot.py | 5 ++++- src/bot/config.py | 11 ++++++++++- src/bot/db/models.py | 3 +++ src/bot/game/config.py | 0 src/bot/game/logic/battle.py | 0 src/bot/handlers/default.py | 15 ++++++++++++--- 6 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 src/bot/game/config.py create mode 100644 src/bot/game/logic/battle.py diff --git a/src/bot/bot.py b/src/bot/bot.py index 9aa8439..5117823 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -8,9 +8,12 @@ bot = Bot(token=ConfigService.BOT_API_KEY) async def bootstrap() -> None: + from handlers.default import default_router dp = Dispatcher() - dp.include_routers() + dp.include_routers( + default_router + ) await dp.start_polling(bot) diff --git a/src/bot/config.py b/src/bot/config.py index 452fdfc..29b704b 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -1,10 +1,19 @@ 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") \ No newline at end of file + DATABASE_URL = getenv("DATABASE_URL") + +@dataclass +class TelegramTextMap: + async def GREETING_TEXT(ctx: Message or CallbackQuery): + return f"Привет, {ctx.from_user.first_name}" + + async def MAINMENU_TEXT(ctx: Message or CallbackQuery): + return f"{ctx.from_user.first_name}, выбери действие:" diff --git a/src/bot/db/models.py b/src/bot/db/models.py index 2a3b18c..c308059 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -53,3 +53,6 @@ class InventoryItem(Model): def __str__(self) -> str: return f"" + +class Enemy(Model): + pass \ 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/battle.py b/src/bot/game/logic/battle.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bot/handlers/default.py b/src/bot/handlers/default.py index c7e744d..ad80364 100644 --- a/src/bot/handlers/default.py +++ b/src/bot/handlers/default.py @@ -1,9 +1,18 @@ from aiogram import Router, F from aiogram.filters import Command, CommandStart -from aiogram.types import Message, Callback +from aiogram.types import Message, CallbackQuery +from ..db.models import User +from ..config import TelegramTextMap default_router = Router("default_router") @default_router.message(CommandStart()) -async def start_commands(message: Message) -> None: - await message.reply(text=f'') +async def command_start(message: Message) -> None: + user = await User.get_or_create(telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name) + await message.reply(text=TelegramTextMap.GREETING_TEXT(message)) + +@default_router.callback_query(F.data == "mainmenu") +async def callback_mainmenu(callback: CallbackQuery) -> None: + user = await User.get(telegram_id=callback.from_user.id) + await callback.message.edit_text(text=TelegramTextMap.MAINMENU_TEXT(callback)) + From acbdee0b539fb4c75fe4dd8ef80f21d57b872edd Mon Sep 17 00:00:00 2001 From: hell4uk Date: Fri, 31 Oct 2025 21:42:58 +0500 Subject: [PATCH 05/28] Refactor inventory handling by renaming Inventory to InventoryItem and updating related methods in InventoryService. Clean up unused imports and ensure consistent return types across inventory operations. --- src/bot/config.py | 2 +- src/bot/game/logic/battle.py | 1 + src/bot/handlers/callback/inventory.py | 8 ++++++++ src/bot/handlers/default.py | 1 - src/bot/keyboards/inlines/keyboard.py | 7 +++++++ src/bot/services/inventory.py | 28 +++++++++++++------------- 6 files changed, 31 insertions(+), 16 deletions(-) create mode 100644 src/bot/handlers/callback/inventory.py create mode 100644 src/bot/keyboards/inlines/keyboard.py diff --git a/src/bot/config.py b/src/bot/config.py index 29b704b..48c7554 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -16,4 +16,4 @@ async def GREETING_TEXT(ctx: Message or CallbackQuery): return f"Привет, {ctx.from_user.first_name}" async def MAINMENU_TEXT(ctx: Message or CallbackQuery): - return f"{ctx.from_user.first_name}, выбери действие:" + return f"{ctx.from_user.first_name}, выбери действие:" \ No newline at end of file diff --git a/src/bot/game/logic/battle.py b/src/bot/game/logic/battle.py index e69de29..6132d80 100644 --- a/src/bot/game/logic/battle.py +++ b/src/bot/game/logic/battle.py @@ -0,0 +1 @@ +from math import * diff --git a/src/bot/handlers/callback/inventory.py b/src/bot/handlers/callback/inventory.py new file mode 100644 index 0000000..02c3117 --- /dev/null +++ b/src/bot/handlers/callback/inventory.py @@ -0,0 +1,8 @@ +from aiogram import F, Router +from aiogram.types import CallbackQuery + +callback_inventory_router = Router("callback_inventory_router") + +@callback_inventory_router.callback_query(F.data == '') +async def callback_inventory(): + pass \ No newline at end of file diff --git a/src/bot/handlers/default.py b/src/bot/handlers/default.py index ad80364..12405ef 100644 --- a/src/bot/handlers/default.py +++ b/src/bot/handlers/default.py @@ -15,4 +15,3 @@ async def command_start(message: Message) -> None: async def callback_mainmenu(callback: CallbackQuery) -> None: user = await User.get(telegram_id=callback.from_user.id) await callback.message.edit_text(text=TelegramTextMap.MAINMENU_TEXT(callback)) - diff --git a/src/bot/keyboards/inlines/keyboard.py b/src/bot/keyboards/inlines/keyboard.py new file mode 100644 index 0000000..204354d --- /dev/null +++ b/src/bot/keyboards/inlines/keyboard.py @@ -0,0 +1,7 @@ +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder +from ...config import TelegramTextMap + + +async def mainmenu_keyboard() -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() diff --git a/src/bot/services/inventory.py b/src/bot/services/inventory.py index 0a0ee20..bd02fad 100644 --- a/src/bot/services/inventory.py +++ b/src/bot/services/inventory.py @@ -1,6 +1,6 @@ from typing import Optional, List -from tortoise.exceptions import DoesNotExist, IntegrityError -from bot.db.models import Inventory, User, Item +from tortoise.exceptions import DoesNotExist +from bot.db.models import InventoryItem, User, Item from bot.services.item import ItemService @@ -9,9 +9,9 @@ def __init__(self) -> None: self.item_service = ItemService() - async def add(self, user: User, item: Item, quantity: int = 1, auto_create: bool = True) -> Inventory: + async def add(self, user: User, item: Item, quantity: int = 1, auto_create: bool = True) -> InventoryItem: try: - inv = await Inventory.get(user=user, item=item) + inv = await InventoryItem.get(user=user, item=item) inv.quantity += quantity await inv.save() return inv @@ -20,11 +20,11 @@ async def add(self, user: User, item: Item, quantity: int = 1, auto_create: bool if not auto_create: raise ValueError("Item not found in inventory and auto_create=False") - inv = await Inventory.create(user=user, item=item, quantity=quantity) + inv = await InventoryItem.create(user=user, item=item, quantity=quantity) return inv - async def remove(self, user: User, item: Item, quantity: int = 1) -> Inventory: - inv = await Inventory.get_or_none(user=user, item=item) + async def remove(self, user: User, item: Item, quantity: int = 1) -> InventoryItem: + inv = await InventoryItem.get_or_none(user=user, item=item) if not inv: raise ValueError("Item not found in inventory") @@ -38,15 +38,15 @@ async def remove(self, user: User, item: Item, quantity: int = 1) -> Inventory: else: await inv.save() - async def get(self, user: User) -> List[Inventory]: - inv = await Inventory.filter(user=user).prefetch_related('item') + async def get(self, user: User) -> Optional[List[InventoryItem]]: + inv = await InventoryItem.filter(user=user).prefetch_related('item') return inv async def clear(self, user: User) -> None: - await Inventory.filter(user=user).delete() + await InventoryItem.filter(user=user).delete() - async def equip_item(self, user: User, item: Item) -> Inventory: - inv = await Inventory.get_or_none(user=user, item=item) + async def equip_item(self, user: User, item: Item) -> InventoryItem: + inv = await InventoryItem.get_or_none(user=user, item=item) if not inv: raise ValueError("Item not found in inventory") @@ -55,8 +55,8 @@ async def equip_item(self, user: User, item: Item) -> Inventory: return inv - async def unequip_item(self, user: User, item: Item) -> Inventory: - inv = await Inventory.get_or_none(user=user, item=item) + async def unequip_item(self, user: User, item: Item) -> InventoryItem: + inv = await InventoryItem.get_or_none(user=user, item=item) if not inv: raise ValueError("Item not found in inventory") From 67dda7fc50b9e504f089e5a9e60e5a4c762b72f0 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Fri, 31 Oct 2025 22:02:12 +0500 Subject: [PATCH 06/28] Refactor bot structure by updating import paths, enhancing router configurations, and modifying model fields for better clarity. Change description field type in Item model and ensure consistent naming in ForeignKey relationships. Update command handling to use await for text responses. --- src/bot/bot.py | 10 ++++++++-- src/bot/db/database.py | 4 ++-- src/bot/db/models.py | 4 ++-- src/bot/handlers/callback/battle.py | 8 ++++++++ src/bot/handlers/callback/inventory.py | 2 +- src/bot/handlers/callback/market.py | 8 ++++++++ src/bot/handlers/default.py | 6 +++--- 7 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 src/bot/handlers/callback/battle.py create mode 100644 src/bot/handlers/callback/market.py diff --git a/src/bot/bot.py b/src/bot/bot.py index 5117823..e4c530e 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -8,11 +8,17 @@ bot = Bot(token=ConfigService.BOT_API_KEY) async def bootstrap() -> None: - from handlers.default import default_router + from src.bot.handlers.default import default_router + from src.bot.handlers.callback.battle import callback_battle_router + from src.bot.handlers.callback.inventory import callback_inventory_router + from src.bot.handlers.callback.market import callback_market_router dp = Dispatcher() dp.include_routers( - default_router + default_router, + callback_market_router, + callback_battle_router, + callback_inventory_router, ) await dp.start_polling(bot) diff --git a/src/bot/db/database.py b/src/bot/db/database.py index 64b2c74..6c028b0 100644 --- a/src/bot/db/database.py +++ b/src/bot/db/database.py @@ -6,10 +6,10 @@ logger = logging.getLogger(__name__) TORTOISE_ORM = { - "connection": {"default": ConfigService.DATABASE_URL}, + "connections": {"default": ConfigService.DATABASE_URL}, "apps": { "models": { - "models": ["bot.db.models", "aerich.models"], + "models": ["src.bot.db.models", "aerich.models"], "default_connection": "default", }, }, diff --git a/src/bot/db/models.py b/src/bot/db/models.py index c308059..b00b448 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -31,7 +31,7 @@ class Item(Model): id = fields.BigIntField(pk=True) name = fields.CharField(max_length=225, null=False) - description = fields.CharField(max_length=512, null=True) + description = fields.TextField(max_length=512, null=True) type = fields.CharEnumField(ItemTypeEnum) attributes = fields.JSONField(default=dict) rarity = fields.CharEnumField(ItemRarityEnum, default=ItemRarityEnum.COMMON) @@ -45,7 +45,7 @@ class InventoryItem(Model): id = fields.BigIntField(pk=True) user = fields.ForeignKeyField('models.User', related_name="inventory_items") - item = fields.ForeignKeyField('models.item', related_name='instances') + item = fields.ForeignKeyField('models.Item', related_name='instances') quantity = fields.IntField(default=1) equipped = fields.BooleanField(default=True) diff --git a/src/bot/handlers/callback/battle.py b/src/bot/handlers/callback/battle.py new file mode 100644 index 0000000..fb76301 --- /dev/null +++ b/src/bot/handlers/callback/battle.py @@ -0,0 +1,8 @@ +from aiogram import Router, F +from aiogram.types import CallbackQuery + +callback_battle_router = Router() + +@callback_battle_router.callback_query(F.data == '') +async def callback_battle(callback: CallbackQuery) -> None: + pass diff --git a/src/bot/handlers/callback/inventory.py b/src/bot/handlers/callback/inventory.py index 02c3117..f537a7a 100644 --- a/src/bot/handlers/callback/inventory.py +++ b/src/bot/handlers/callback/inventory.py @@ -1,7 +1,7 @@ from aiogram import F, Router from aiogram.types import CallbackQuery -callback_inventory_router = Router("callback_inventory_router") +callback_inventory_router = Router() @callback_inventory_router.callback_query(F.data == '') async def callback_inventory(): diff --git a/src/bot/handlers/callback/market.py b/src/bot/handlers/callback/market.py new file mode 100644 index 0000000..50efefd --- /dev/null +++ b/src/bot/handlers/callback/market.py @@ -0,0 +1,8 @@ +from aiogram import Router, F +from aiogram.types import CallbackQuery + +callback_market_router = Router() + +@callback_market_router.callback_query(F.data == '') +async def callback_market(callback: CallbackQuery) -> None: + pass \ No newline at end of file diff --git a/src/bot/handlers/default.py b/src/bot/handlers/default.py index 12405ef..7cd777f 100644 --- a/src/bot/handlers/default.py +++ b/src/bot/handlers/default.py @@ -4,14 +4,14 @@ from ..db.models import User from ..config import TelegramTextMap -default_router = Router("default_router") +default_router = Router() @default_router.message(CommandStart()) async def command_start(message: Message) -> None: user = await User.get_or_create(telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name) - await message.reply(text=TelegramTextMap.GREETING_TEXT(message)) + await message.reply(text=await TelegramTextMap.GREETING_TEXT(message)) @default_router.callback_query(F.data == "mainmenu") async def callback_mainmenu(callback: CallbackQuery) -> None: user = await User.get(telegram_id=callback.from_user.id) - await callback.message.edit_text(text=TelegramTextMap.MAINMENU_TEXT(callback)) + await callback.message.edit_text(text=await TelegramTextMap.MAINMENU_TEXT(callback)) From 34e29dee7e766f0bb8a17e8ae119f887d3348c57 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Fri, 31 Oct 2025 22:12:09 +0500 Subject: [PATCH 07/28] Rename User, Item, and InventoryItem models to Users, Items, and InventoryItems for consistency. Update related references throughout the codebase, ensuring ForeignKey relationships reflect the new naming conventions. Enhance InventoryItems model with unique constraints and table metadata. --- src/bot/db/models.py | 16 ++++++++++------ src/bot/handlers/default.py | 6 +++--- src/bot/services/inventory.py | 28 ++++++++++++++-------------- src/bot/services/item.py | 16 ++++++++-------- 4 files changed, 35 insertions(+), 31 deletions(-) diff --git a/src/bot/db/models.py b/src/bot/db/models.py index b00b448..3b67603 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -14,7 +14,7 @@ class ItemRarityEnum(Enum): COMMON = 'common' -class User(Model): +class Users(Model): id = fields.BigIntField(pk=True) telegram_id = fields.BigIntField(unique=True) @@ -27,7 +27,7 @@ class User(Model): def __str__(self) -> str: return f"" -class Item(Model): +class Items(Model): id = fields.BigIntField(pk=True) name = fields.CharField(max_length=225, null=False) @@ -41,18 +41,22 @@ class Item(Model): def __str__(self) -> str: return f"" -class InventoryItem(Model): +class InventoryItems(Model): id = fields.BigIntField(pk=True) - user = fields.ForeignKeyField('models.User', related_name="inventory_items") - item = fields.ForeignKeyField('models.Item', related_name='instances') + 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=True) acquired_at = fields.DatetimeField(auto_now_add=True) + class Meta(): + table = 'inventory_items' + unique_together = ('user', 'item') + def __str__(self) -> str: return f"" -class Enemy(Model): +class Enemies(Model): pass \ No newline at end of file diff --git a/src/bot/handlers/default.py b/src/bot/handlers/default.py index 7cd777f..ec811e9 100644 --- a/src/bot/handlers/default.py +++ b/src/bot/handlers/default.py @@ -1,17 +1,17 @@ from aiogram import Router, F from aiogram.filters import Command, CommandStart from aiogram.types import Message, CallbackQuery -from ..db.models import User +from ..db.models import Users from ..config import TelegramTextMap default_router = Router() @default_router.message(CommandStart()) async def command_start(message: Message) -> None: - user = await User.get_or_create(telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name) + user = await Users.get_or_create(telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name) await message.reply(text=await TelegramTextMap.GREETING_TEXT(message)) @default_router.callback_query(F.data == "mainmenu") async def callback_mainmenu(callback: CallbackQuery) -> None: - user = await User.get(telegram_id=callback.from_user.id) + user = await Users.get(telegram_id=callback.from_user.id) await callback.message.edit_text(text=await TelegramTextMap.MAINMENU_TEXT(callback)) diff --git a/src/bot/services/inventory.py b/src/bot/services/inventory.py index bd02fad..c1ea6a8 100644 --- a/src/bot/services/inventory.py +++ b/src/bot/services/inventory.py @@ -1,6 +1,6 @@ from typing import Optional, List from tortoise.exceptions import DoesNotExist -from bot.db.models import InventoryItem, User, Item +from bot.db.models import InventoryItems, Users, Items from bot.services.item import ItemService @@ -9,9 +9,9 @@ def __init__(self) -> None: self.item_service = ItemService() - async def add(self, user: User, item: Item, quantity: int = 1, auto_create: bool = True) -> InventoryItem: + async def add(self, user: Users, item: Items, quantity: int = 1, auto_create: bool = True) -> InventoryItems: try: - inv = await InventoryItem.get(user=user, item=item) + inv = await InventoryItems.get(user=user, item=item) inv.quantity += quantity await inv.save() return inv @@ -20,11 +20,11 @@ async def add(self, user: User, item: Item, quantity: int = 1, auto_create: bool if not auto_create: raise ValueError("Item not found in inventory and auto_create=False") - inv = await InventoryItem.create(user=user, item=item, quantity=quantity) + inv = await InventoryItems.create(user=user, item=item, quantity=quantity) return inv - async def remove(self, user: User, item: Item, quantity: int = 1) -> InventoryItem: - inv = await InventoryItem.get_or_none(user=user, item=item) + async def remove(self, user: Users, item: Items, quantity: int = 1) -> InventoryItems: + inv = await InventoryItems.get_or_none(user=user, item=item) if not inv: raise ValueError("Item not found in inventory") @@ -38,15 +38,15 @@ async def remove(self, user: User, item: Item, quantity: int = 1) -> InventoryIt else: await inv.save() - async def get(self, user: User) -> Optional[List[InventoryItem]]: - inv = await InventoryItem.filter(user=user).prefetch_related('item') + async def get(self, user: Users) -> Optional[List[InventoryItems]]: + inv = await InventoryItems.filter(user=user).prefetch_related('item') return inv - async def clear(self, user: User) -> None: - await InventoryItem.filter(user=user).delete() + async def clear(self, user: Users) -> None: + await InventoryItems.filter(user=user).delete() - async def equip_item(self, user: User, item: Item) -> InventoryItem: - inv = await InventoryItem.get_or_none(user=user, item=item) + async def equip_item(self, user: Users, item: Items) -> InventoryItems: + inv = await InventoryItems.get_or_none(user=user, item=item) if not inv: raise ValueError("Item not found in inventory") @@ -55,8 +55,8 @@ async def equip_item(self, user: User, item: Item) -> InventoryItem: return inv - async def unequip_item(self, user: User, item: Item) -> InventoryItem: - inv = await InventoryItem.get_or_none(user=user, item=item) + async def unequip_item(self, user: Users, item: Items) -> InventoryItems: + inv = await InventoryItems.get_or_none(user=user, item=item) if not inv: raise ValueError("Item not found in inventory") diff --git a/src/bot/services/item.py b/src/bot/services/item.py index ec3107f..9a44842 100644 --- a/src/bot/services/item.py +++ b/src/bot/services/item.py @@ -1,7 +1,7 @@ from typing import Optional, Type, List, Dict from tortoise.exceptions import DoesNotExist -from bot.db.models import Item, InventoryItem, ItemTypeEnum, ItemEnum, ItemRarityEnum +from bot.db.models import Items, InventoryItems, ItemTypeEnum, ItemEnum, ItemRarityEnum from bot.db.schemas.items import ArmorAttributes, BaseAttributes, WeaponAttributes from pydantic import ValidationError @@ -30,21 +30,21 @@ def _validate_attributes(self, item_type: str, attributes: dict) -> dict: raise ValueError(f'Invalid attributes for {item_type}: {_ex.errors()}') - async def create(self, name: str, item_type: ItemTypeEnum, item_rarity: ItemRarityEnum, attributes: dict): + async def create(self, name: str, item_type: ItemTypeEnum, item_rarity: ItemRarityEnum, attributes: dict) -> Items: valid_attrs = self._validate_attributes(item_type, attributes) - item = await Item.get_or_create(name=name, rarity=item_rarity, type=item_type, attributes=valid_attrs) + item = await Items.get_or_create(name=name, rarity=item_rarity, type=item_type, attributes=valid_attrs) return item - async def get_by_id(self, item_id: int) -> Item: - item = await Item.get_or_none(id=item_id) + async def get_by_id(self, item_id: int) -> Items: + item = await Items.get_or_none(id=item_id) return item - async def get_by_name(self, item_name: str) -> Optional[List[Item]]: - items = await Item.get(name=item_name) + async def get_by_name(self, item_name: str) -> Optional[List[Items]]: + items = await Items.get(name=item_name) return items - async def update(self, item_id: str, **updates: any) -> Item: + async def update(self, item_id: str, **updates: any) -> Items: item = await self.get_item(item_id) if "attributes" in updates: From 3c032f976509cdbbdb6796b4c0f54d7b5bfa1fb7 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Sat, 1 Nov 2025 01:07:48 +0500 Subject: [PATCH 08/28] Refactor item and user models by enhancing enum definitions to inherit from str, and adding table metadata and ordering to Users and Items models. Update BaseAttributes and WeaponAttributes schemas with new fields for item properties. Remove unused battle logic file and improve inventory service with a method to retrieve inventory as a dictionary. --- src/bot/db/models.py | 14 +++++++++++--- src/bot/db/schemas/items.py | 16 ++++++++++++---- src/bot/game/logic/calculation.py | 6 ++++++ src/bot/game/{logic => views}/battle.py | 0 src/bot/services/inventory.py | 9 +++++++-- 5 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 src/bot/game/logic/calculation.py rename src/bot/game/{logic => views}/battle.py (100%) diff --git a/src/bot/db/models.py b/src/bot/db/models.py index 3b67603..2318ade 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -6,11 +6,11 @@ from tortoise.models import Model from enum import Enum -class ItemTypeEnum(Enum): +class ItemTypeEnum(str, Enum): WEAPON = 'weapon' ARMOR = 'armor' -class ItemRarityEnum(Enum): +class ItemRarityEnum(str, Enum): COMMON = 'common' @@ -24,6 +24,10 @@ class Users(Model): created_at = fields.DatetimeField(auto_now_add=True) + class Meta: + table = 'users' + ordering = ['id', 'username'] + def __str__(self) -> str: return f"" @@ -38,6 +42,10 @@ class Items(Model): created_at = fields.DatetimeField(auto_now_add=True) + class Meta: + table = 'items' + ordering = ['id', 'name'] + def __str__(self) -> str: return f"" @@ -51,7 +59,7 @@ class InventoryItems(Model): acquired_at = fields.DatetimeField(auto_now_add=True) - class Meta(): + class Meta: table = 'inventory_items' unique_together = ('user', 'item') diff --git a/src/bot/db/schemas/items.py b/src/bot/db/schemas/items.py index 92b8c6a..d0000bf 100644 --- a/src/bot/db/schemas/items.py +++ b/src/bot/db/schemas/items.py @@ -4,11 +4,19 @@ class BaseAttributes(BaseModel): - pass + item_level: int = Field(..., ge=1, default=1) class WeaponAttributes(BaseAttributes): - damage: int = Field(..., ge=1) - critical_chance: float = Field(default= 0.0, ge=0.0, le=1.0) + min_damage: int = Field(..., ge=1, default=1) + max_damage: int = Field(..., ge=1, default=1) + + attack_speed: float = Field(..., ge=1.0, default=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): - pass + defense: int = Field(..., ge=1, default=1) + + health_bonus: int = Field(ge=0, default=0) diff --git a/src/bot/game/logic/calculation.py b/src/bot/game/logic/calculation.py new file mode 100644 index 0000000..63a4339 --- /dev/null +++ b/src/bot/game/logic/calculation.py @@ -0,0 +1,6 @@ +from typing import Dict +from bot.db.models import InventoryItems, ItemRarityEnum, ItemTypeEnum, Users +from bot.services.inventory import InventoryService +from bot.db.schemas.items import WeaponAttributes, ArmorAttributes + +# TODO: Сделать классы для обработки общего урона-брони для используемых предметов \ No newline at end of file diff --git a/src/bot/game/logic/battle.py b/src/bot/game/views/battle.py similarity index 100% rename from src/bot/game/logic/battle.py rename to src/bot/game/views/battle.py diff --git a/src/bot/services/inventory.py b/src/bot/services/inventory.py index c1ea6a8..970c795 100644 --- a/src/bot/services/inventory.py +++ b/src/bot/services/inventory.py @@ -3,7 +3,7 @@ from bot.db.models import InventoryItems, Users, Items from bot.services.item import ItemService - +# TODO: Сделать методы для получения используемых предметов, сделать копии методов для получения (dict), также улучшить все существующие методы class InventoryService(): def __init__(self) -> None: self.item_service = ItemService() @@ -23,7 +23,7 @@ async def add(self, user: Users, item: Items, quantity: int = 1, auto_create: bo inv = await InventoryItems.create(user=user, item=item, quantity=quantity) return inv - async def remove(self, user: Users, item: Items, quantity: int = 1) -> InventoryItems: + async def remove(self, user: Users, item: Items, quantity: int = 1) -> None: inv = await InventoryItems.get_or_none(user=user, item=item) if not inv: raise ValueError("Item not found in inventory") @@ -41,6 +41,11 @@ async def remove(self, user: Users, item: Items, quantity: int = 1) -> Inventory 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() From 757bff3490bff5aaf92ea74ee7ed3664fc9c579f Mon Sep 17 00:00:00 2001 From: hell4uk Date: Sat, 1 Nov 2025 01:39:49 +0500 Subject: [PATCH 09/28] Enhance bot configuration by validating `BOT_API_KEY` in the environment. Refactor TelegramTextMap methods to be static for improved clarity. Clean up unused imports in models and update item retrieval methods to use more efficient querying. Ensure consistent return types across item service methods. --- src/bot/bot.py | 2 +- src/bot/config.py | 6 ++++-- src/bot/db/models.py | 4 ---- src/bot/services/inventory.py | 2 ++ src/bot/services/item.py | 13 +++++++++---- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/bot/bot.py b/src/bot/bot.py index e4c530e..e879cbe 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -2,7 +2,7 @@ from .config import ConfigService -if not ConfigService: +if not ConfigService.BOT_API_KEY: raise Exception("You should enter `BOT_API_KEY` in .env") bot = Bot(token=ConfigService.BOT_API_KEY) diff --git a/src/bot/config.py b/src/bot/config.py index 48c7554..a8eed4d 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -12,8 +12,10 @@ class ConfigService: @dataclass class TelegramTextMap: - async def GREETING_TEXT(ctx: Message or CallbackQuery): + @staticmethod + async def GREETING_TEXT(ctx: Message | CallbackQuery): return f"Привет, {ctx.from_user.first_name}" - async def MAINMENU_TEXT(ctx: Message or CallbackQuery): + @staticmethod + async def MAINMENU_TEXT(ctx: Message | CallbackQuery): return f"{ctx.from_user.first_name}, выбери действие:" \ No newline at end of file diff --git a/src/bot/db/models.py b/src/bot/db/models.py index 2318ade..859cbab 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -1,7 +1,3 @@ -from email.policy import default -from enum import unique -import re -from statistics import quantiles from tortoise import fields from tortoise.models import Model from enum import Enum diff --git a/src/bot/services/inventory.py b/src/bot/services/inventory.py index 970c795..e502f49 100644 --- a/src/bot/services/inventory.py +++ b/src/bot/services/inventory.py @@ -51,6 +51,8 @@ async def clear(self, user: Users) -> None: await InventoryItems.filter(user=user).delete() async def equip_item(self, user: Users, item: Items) -> InventoryItems: + await InventoryItems.filter(user=user, item__type=item.type, equipped=True).update(equipped=False) + inv = await InventoryItems.get_or_none(user=user, item=item) if not inv: raise ValueError("Item not found in inventory") diff --git a/src/bot/services/item.py b/src/bot/services/item.py index 9a44842..7573ffb 100644 --- a/src/bot/services/item.py +++ b/src/bot/services/item.py @@ -33,19 +33,24 @@ def _validate_attributes(self, item_type: str, attributes: dict) -> dict: async def create(self, name: str, item_type: ItemTypeEnum, item_rarity: ItemRarityEnum, attributes: dict) -> Items: valid_attrs = self._validate_attributes(item_type, attributes) - item = await Items.get_or_create(name=name, rarity=item_rarity, type=item_type, attributes=valid_attrs) + item = 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) -> Items: + 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.get(name=item_name) + items = await Items.filter(name=item_name).all() return items async def update(self, item_id: str, **updates: any) -> Items: - item = await self.get_item(item_id) + item = await self.get_by_id(item_id) if "attributes" in updates: item.attributes = self._validate_attributes(item.type, updates["attributes"]) From 83fb96ac17535a70d1300c88e4d762691f1ae1ff Mon Sep 17 00:00:00 2001 From: hell4uk Date: Wed, 5 Nov 2025 12:32:32 +0500 Subject: [PATCH 10/28] Refactor inventory system to enforce single item equipping per type, update equipped status handling in InventoryService, and enhance damage and armor calculation logic with new classes for better modularity. Adjust InventoryItems model to set default equipped status to False. --- src/bot/bot.py | 3 +- src/bot/db/models.py | 2 +- src/bot/game/logic/calculation.py | 49 ++++++++++++++++++++++++++++++- src/bot/handlers/default.py | 1 + src/bot/services/inventory.py | 33 ++++++++++++++++----- 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/bot/bot.py b/src/bot/bot.py index e879cbe..1d2c9dd 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -21,5 +21,4 @@ async def bootstrap() -> None: callback_inventory_router, ) - await dp.start_polling(bot) - + await dp.start_polling(bot) \ No newline at end of file diff --git a/src/bot/db/models.py b/src/bot/db/models.py index 859cbab..c638f8c 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -51,7 +51,7 @@ class InventoryItems(Model): 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=True) + equipped = fields.BooleanField(default=False) acquired_at = fields.DatetimeField(auto_now_add=True) diff --git a/src/bot/game/logic/calculation.py b/src/bot/game/logic/calculation.py index 63a4339..4ef7755 100644 --- a/src/bot/game/logic/calculation.py +++ b/src/bot/game/logic/calculation.py @@ -2,5 +2,52 @@ from bot.db.models import InventoryItems, ItemRarityEnum, ItemTypeEnum, Users from bot.services.inventory import InventoryService from bot.db.schemas.items import WeaponAttributes, ArmorAttributes +from random import random, gauss, gammavariate +from math import sqrt, exp, log10, floor -# TODO: Сделать классы для обработки общего урона-брони для используемых предметов \ No newline at end of file +# TODO: Сделать классы для обработки общего урона-брони для используемых предметов + +class BaseCalculation: + def __init__(self, user: Users) -> None: + self.user = user + self.inventory_service = InventoryService() + +class DamageCalculation(BaseCalculation): + def __init__(self, user: Users) -> None: + super().__init__(user) + + async def calculate_damage(self) -> int: + equipped_item = await self.inventory_service.get_equipped_item(self.user, ItemTypeEnum.WEAPON) + if not equipped_item: + return 0 + + weapon_attributes = equipped_item.item.attributes + + damage = gauss(weapon_attributes.min_damage, weapon_attributes.max_damage) + if random() < weapon_attributes.critical_chance: + damage *= weapon_attributes.critical_multiplier + + damage *= weapon_attributes.attack_speed + damage *= 1 + (weapon_attributes.item_level * 0.02) + damage = floor(damage) + + return damage + + +class ArmorCalculation(BaseCalculation): + def __init__(self, user: Users) -> None: + super().__init__(user) + + async def calculate_armor(self) -> float: + equipped_item = await self.inventory_service.get_equipped_item(self.user, ItemTypeEnum.ARMOR) + if not equipped_item: + return 0 + + armor_attributes = equipped_item.item.attributes + + armor = armor_attributes.defense + armor *= 1 + (armor_attributes.health_bonus * random()) + armor *= 1 + (armor_attributes.item_level * 0.02) + armor = floor(armor) + + return armor \ No newline at end of file diff --git a/src/bot/handlers/default.py b/src/bot/handlers/default.py index ec811e9..6730863 100644 --- a/src/bot/handlers/default.py +++ b/src/bot/handlers/default.py @@ -15,3 +15,4 @@ async def command_start(message: Message) -> None: async def callback_mainmenu(callback: CallbackQuery) -> None: user = await Users.get(telegram_id=callback.from_user.id) await callback.message.edit_text(text=await TelegramTextMap.MAINMENU_TEXT(callback)) + \ No newline at end of file diff --git a/src/bot/services/inventory.py b/src/bot/services/inventory.py index e502f49..6470a40 100644 --- a/src/bot/services/inventory.py +++ b/src/bot/services/inventory.py @@ -1,9 +1,10 @@ from typing import Optional, List from tortoise.exceptions import DoesNotExist -from bot.db.models import InventoryItems, Users, Items +from bot.db.models import InventoryItems, Users, Items, ItemTypeEnum from bot.services.item import ItemService # TODO: Сделать методы для получения используемых предметов, сделать копии методов для получения (dict), также улучшить все существующие методы +# TODO: Сделать систему при который можно одеть только один предмет каждого типа class InventoryService(): def __init__(self) -> None: self.item_service = ItemService() @@ -51,22 +52,40 @@ async def clear(self, user: Users) -> None: await InventoryItems.filter(user=user).delete() async def equip_item(self, user: Users, item: Items) -> InventoryItems: - await InventoryItems.filter(user=user, item__type=item.type, equipped=True).update(equipped=False) - 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) -> InventoryItems: + + async def unequip_item(self, user: Users, item: Items) -> None: 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() - return inv + + async def get_equipped_item(self, user: Users, item_type: ItemTypeEnum) -> Optional[InventoryItems]: + return await InventoryItems.filter( + user=user, + equipped=True, + item__type=item_type + ).prefetch_related("item").first() \ No newline at end of file From 4ef88f4b11b33ecd425b3c02774e65cc29589f3f Mon Sep 17 00:00:00 2001 From: hell4uk Date: Wed, 5 Nov 2025 12:39:26 +0500 Subject: [PATCH 11/28] Update default command handling to include main menu keyboard in responses and remove unused keyboard file. Enhance user interaction by providing a consistent reply markup for greeting and main menu callbacks. --- src/bot/handlers/default.py | 5 +++-- src/bot/keyboards/inlines/battle.py | 0 src/bot/keyboards/inlines/{keyboard.py => default.py} | 6 ++++++ src/bot/keyboards/inlines/market.py | 0 src/bot/services/enemy.py | 8 ++++++++ 5 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 src/bot/keyboards/inlines/battle.py rename src/bot/keyboards/inlines/{keyboard.py => default.py} (50%) create mode 100644 src/bot/keyboards/inlines/market.py create mode 100644 src/bot/services/enemy.py diff --git a/src/bot/handlers/default.py b/src/bot/handlers/default.py index 6730863..a8c0837 100644 --- a/src/bot/handlers/default.py +++ b/src/bot/handlers/default.py @@ -3,16 +3,17 @@ from aiogram.types import Message, CallbackQuery from ..db.models import Users from ..config import TelegramTextMap +from ..keyboards.inlines.default import mainmenu_keyboard default_router = Router() @default_router.message(CommandStart()) async def command_start(message: Message) -> None: user = await Users.get_or_create(telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name) - await message.reply(text=await TelegramTextMap.GREETING_TEXT(message)) + 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 Users.get(telegram_id=callback.from_user.id) - await callback.message.edit_text(text=await TelegramTextMap.MAINMENU_TEXT(callback)) + 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..e69de29 diff --git a/src/bot/keyboards/inlines/keyboard.py b/src/bot/keyboards/inlines/default.py similarity index 50% rename from src/bot/keyboards/inlines/keyboard.py rename to src/bot/keyboards/inlines/default.py index 204354d..a63a0f5 100644 --- a/src/bot/keyboards/inlines/keyboard.py +++ b/src/bot/keyboards/inlines/default.py @@ -5,3 +5,9 @@ async def mainmenu_keyboard() -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() + + kb.button(text='Начать сражение', callback_data='battle') + kb.button(text='Инвентарь', callback_data='inv') + kb.button(text='Рынок', callback_data='market') + + return kb.adjust(1).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..e69de29 diff --git a/src/bot/services/enemy.py b/src/bot/services/enemy.py new file mode 100644 index 0000000..4e00cc9 --- /dev/null +++ b/src/bot/services/enemy.py @@ -0,0 +1,8 @@ +from ..db.models import Enemies, Items + +class EnemyService(): + def __init__(self) -> None: + pass + + async def get_by_id(self, id: int) -> Enemies: + pass \ No newline at end of file From ca5687197e2faa97983c69964ec0d53cb74853d6 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Wed, 5 Nov 2025 13:23:21 +0500 Subject: [PATCH 12/28] Add enemy model and calculation logic to enhance gameplay dynamics. Introduce EnemyTypeEnum for enemy classification, and implement EnemyCalculator for dynamic enemy stats and rewards based on player level. Update EnemyService to manage enemy spawning and rewards, integrating drop mechanics and level-up functionality for users. --- src/bot/db/models.py | 40 +++++++++++++++++- src/bot/game/logic/calculation.py | 66 +++++++++++++++++++++++++++++- src/bot/services/enemy.py | 68 +++++++++++++++++++++++++++++-- src/bot/services/inventory.py | 2 +- 4 files changed, 168 insertions(+), 8 deletions(-) diff --git a/src/bot/db/models.py b/src/bot/db/models.py index c638f8c..c675b1d 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -9,6 +9,10 @@ class ItemTypeEnum(str, Enum): class ItemRarityEnum(str, Enum): COMMON = 'common' +class EnemyTypeEnum(str, Enum): + COMMON = 'common' + ELITE = 'elite' + BOSS = 'boss' class Users(Model): id = fields.BigIntField(pk=True) @@ -18,7 +22,12 @@ class Users(Model): 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) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) class Meta: table = 'users' @@ -37,6 +46,7 @@ class Items(Model): rarity = fields.CharEnumField(ItemRarityEnum, default=ItemRarityEnum.COMMON) created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) class Meta: table = 'items' @@ -54,7 +64,6 @@ class InventoryItems(Model): equipped = fields.BooleanField(default=False) acquired_at = fields.DatetimeField(auto_now_add=True) - class Meta: table = 'inventory_items' unique_together = ('user', 'item') @@ -63,4 +72,31 @@ def __str__(self) -> str: return f"" class Enemies(Model): - pass \ No newline at end of file + id = fields.BigIntField(pk=True) + + name = fields.CharField(max_length=255, null=False) + description = fields.TextField(max_length=512, null=True, default='') + + type = fields.CharEnumField(EnemyTypeEnum, default=EnemyTypeEnum.COMMON) + + health_multiplier = fields.FloatField(default=0.5) + damage_multiplier = fields.FloatField(default=0.5) + + gold_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 = ["name"] diff --git a/src/bot/game/logic/calculation.py b/src/bot/game/logic/calculation.py index 4ef7755..d0006b3 100644 --- a/src/bot/game/logic/calculation.py +++ b/src/bot/game/logic/calculation.py @@ -50,4 +50,68 @@ async def calculate_armor(self) -> float: armor *= 1 + (armor_attributes.item_level * 0.02) armor = floor(armor) - return armor \ No newline at end of file + return armor + +# src/bot/game/calculate/enemy.py +from math import floor +from bot.db.models import Enemies +from typing import Dict + + +class EnemyCalculator: + BASE_HP = 12 + BASE_DAMAGE = 9 + BASE_GOLD = 18 + BASE_EXP = 25 + + RARITY_BONUS = { + "common": 1.0, + "elite": 2.3, + "boss": 8.0, + } + + def get_stats(self, enemy: Enemies, player_level: int) -> Dict[str, int]: + rarity_bonus = self.RARITY_BONUS.get(enemy.type.value, 1.0) + + hp = floor( + player_level + * enemy.health_multiplier + * self.BASE_HP + * rarity_bonus + ) + damage = floor( + player_level + * enemy.damage_multiplier + * self.BASE_DAMAGE + * rarity_bonus + ) + + return { + "hp": max(hp, 1), + "damage": max(damage, 1), + } + + def get_rewards(self, enemy: Enemies, player_level: int) -> Dict[str, int]: + rarity_bonus = self.RARITY_BONUS.get(enemy.type.value, 1.0) + + gold = floor( + player_level + * enemy.gold_reward_multiplier + * self.BASE_GOLD + * rarity_bonus + ) + exp = floor( + player_level + * enemy.exp_reward_multiplier + * self.BASE_EXP + * rarity_bonus + ) + + return { + "gold": max(gold, 1), + "exp": max(exp, 1), + } + + def get_drop_chance(self, enemy: Enemies, player_level: int) -> float: + 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/services/enemy.py b/src/bot/services/enemy.py index 4e00cc9..4cdedd6 100644 --- a/src/bot/services/enemy.py +++ b/src/bot/services/enemy.py @@ -1,8 +1,68 @@ -from ..db.models import Enemies, Items +from bot.services.inventory import InventoryService +from ..db.models import Enemies, Items, Users, EnemyTypeEnum +from typing import List, Dict +from random import choice, random +from math import floor class EnemyService(): def __init__(self) -> None: - pass + self.calculator = EnemyCalculator() + self.inventory = InventoryService() - async def get_by_id(self, id: int) -> Enemies: - pass \ No newline at end of file + async def spawn(self, user: Users) -> Enemies: + level = max(1, user.lvl) + + rarity = self._get_rarity(level) + + candidates = await Enemies.filter(type=rarity, is_active=True).all() + + if not candidates: + raise ValueError("Unsupported users statistics") + + return choice(candidates) + + async def get_battle_stats(self, enemy: Enemies, user: Users) -> Dict: + return self.calc.get_stats(enemy, user.lvl) + + async def give_reward(self, user: Users, enemy: Enemies) -> Dict: + stats = self.calc.get_reward(enemy, user.lvl) + + user.coins += stats['gold'] + user.exp += stats['exp'] + + leveled_up = await self._level_up(user) + await user.save() + + drops = await self._roll_drops(enemy) + for drop in drops: + await self.inventory.add(user, drop['item'], drop['quantity']) + + return { + "gold": stats['gold'], + "exp": stats['exp'], + 'level_up': leveled_up, + 'drop': [f"{d['item'].name}" for d in drops] + } + + def _get_rarity(self, level: int) -> EnemyTypeEnum: + if level >= 20: + return choice([EnemyTypeEnum.BOSS] + [EnemyTypeEnum.ELITE] * 3 + [EnemyTypeEnum.COMMON] * 4) + if level >= 8: + return choice([EnemyTypeEnum.ELITE] * 2 + [EnemyTypeEnum.COMMON] * 8) + return EnemyTypeEnum.COMMON + + async def _roll_drops(self, enemy: Enemies) -> List[Dict]: + if random() * 100 > enemy.drop_chance: + return [] + + drops = [] + async for item in enemy.drop_items.all(): + if random() < 0.75: + drops.append({"item": item, "quantity": 1}) + return drops + + async def _level_up(self, user: Users) -> bool: + old = user.lvl + while user.exp >= 65 * (user.lvl + 1): + user.lvl += 1 + return user.lvl > old \ No newline at end of file diff --git a/src/bot/services/inventory.py b/src/bot/services/inventory.py index 6470a40..3b5df55 100644 --- a/src/bot/services/inventory.py +++ b/src/bot/services/inventory.py @@ -9,7 +9,7 @@ class InventoryService(): def __init__(self) -> None: self.item_service = ItemService() - + # ? --- CRUD методы --- async def add(self, user: Users, item: Items, quantity: int = 1, auto_create: bool = True) -> InventoryItems: try: inv = await InventoryItems.get(user=user, item=item) From d3b0acb7ff8c64cd25425c804dab15d0528e7d10 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Thu, 6 Nov 2025 20:22:34 +0500 Subject: [PATCH 13/28] Refactor EnemyService to improve clarity and consistency in enemy reward calculations. Rename `get_reward` method to `get_rewards` for better alignment with functionality. Update variable names for enhanced readability. --- readme.md | 47 +++++++++++++++++++++++++++++++++++++++ src/bot/services/enemy.py | 5 +++-- 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 readme.md 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/src/bot/services/enemy.py b/src/bot/services/enemy.py index 4cdedd6..96daaef 100644 --- a/src/bot/services/enemy.py +++ b/src/bot/services/enemy.py @@ -1,12 +1,13 @@ from bot.services.inventory import InventoryService from ..db.models import Enemies, Items, Users, EnemyTypeEnum from typing import List, Dict +from ..game.logic.calculation import EnemyCalculator from random import choice, random from math import floor class EnemyService(): def __init__(self) -> None: - self.calculator = EnemyCalculator() + self.calc = EnemyCalculator() self.inventory = InventoryService() async def spawn(self, user: Users) -> Enemies: @@ -25,7 +26,7 @@ async def get_battle_stats(self, enemy: Enemies, user: Users) -> Dict: return self.calc.get_stats(enemy, user.lvl) async def give_reward(self, user: Users, enemy: Enemies) -> Dict: - stats = self.calc.get_reward(enemy, user.lvl) + stats = self.calc.get_rewards(enemy, user.lvl) user.coins += stats['gold'] user.exp += stats['exp'] From 51172619186bad3672bc58076c39496972dc468d Mon Sep 17 00:00:00 2001 From: hell4uk Date: Thu, 6 Nov 2025 20:46:12 +0500 Subject: [PATCH 14/28] Refactor enemy reward system to replace `gold_reward_multiplier` with `coin_reward_multiplier` for consistency. Update `get_rewards` method in `EnemyCalculator` to reflect this change and adjust related calculations. Modify callback handlers for battle, inventory, and market to use specific identifiers for improved clarity. --- src/bot/db/models.py | 2 +- src/bot/game/logic/calculation.py | 51 +++++++++++--------------- src/bot/handlers/callback/battle.py | 2 +- src/bot/handlers/callback/inventory.py | 2 +- src/bot/handlers/callback/market.py | 2 +- src/bot/services/enemy.py | 5 ++- src/bot/services/user.py | 5 +++ 7 files changed, 34 insertions(+), 35 deletions(-) create mode 100644 src/bot/services/user.py diff --git a/src/bot/db/models.py b/src/bot/db/models.py index c675b1d..a8f19da 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -82,7 +82,7 @@ class Enemies(Model): health_multiplier = fields.FloatField(default=0.5) damage_multiplier = fields.FloatField(default=0.5) - gold_reward_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) diff --git a/src/bot/game/logic/calculation.py b/src/bot/game/logic/calculation.py index d0006b3..1eb414f 100644 --- a/src/bot/game/logic/calculation.py +++ b/src/bot/game/logic/calculation.py @@ -1,5 +1,5 @@ from typing import Dict -from bot.db.models import InventoryItems, ItemRarityEnum, ItemTypeEnum, Users +from bot.db.models import InventoryItems, ItemRarityEnum, ItemTypeEnum, Users, Enemies, EnemyTypeEnum from bot.services.inventory import InventoryService from bot.db.schemas.items import WeaponAttributes, ArmorAttributes from random import random, gauss, gammavariate @@ -52,38 +52,33 @@ async def calculate_armor(self) -> float: return armor -# src/bot/game/calculate/enemy.py -from math import floor -from bot.db.models import Enemies -from typing import Dict - +# TODO : Сделать рандомным, подключить userservice и сделать получение lvl. +# ! : Есть критические ошибки в данном сегменте! class EnemyCalculator: - BASE_HP = 12 - BASE_DAMAGE = 9 - BASE_GOLD = 18 - BASE_EXP = 25 - RARITY_BONUS = { - "common": 1.0, - "elite": 2.3, - "boss": 8.0, + EnemyTypeEnum.COMMON: 1.0, + EnemyTypeEnum.ELITE: 2.3, + EnemyTypeEnum.BOSS: 8.0, } - def get_stats(self, enemy: Enemies, player_level: int) -> Dict[str, int]: - rarity_bonus = self.RARITY_BONUS.get(enemy.type.value, 1.0) + def get_stats(self, enemy: Enemies, user: Users) -> Dict[str, int]: + bonus = self.RARITY_BONUS.get(enemy.type, 1.0) + user_damage = DamageCalculation(user).calculate_damage() + user_armor = ArmorCalculation(user).calculate_armor() + hp = floor( - player_level + pass * enemy.health_multiplier - * self.BASE_HP - * rarity_bonus + * (1+ user_armor) * 0.85 + * bonus * 0.85 ) damage = floor( - player_level + pass * enemy.damage_multiplier - * self.BASE_DAMAGE - * rarity_bonus + * (1 + user_damage) * 0.85 + * bonus * 0.85 ) return { @@ -94,21 +89,19 @@ def get_stats(self, enemy: Enemies, player_level: int) -> Dict[str, int]: def get_rewards(self, enemy: Enemies, player_level: int) -> Dict[str, int]: rarity_bonus = self.RARITY_BONUS.get(enemy.type.value, 1.0) - gold = floor( - player_level - * enemy.gold_reward_multiplier - * self.BASE_GOLD + coin = floor( + enemy.coin_reward_multiplier + * self.BASE_COIN * rarity_bonus ) exp = floor( - player_level - * enemy.exp_reward_multiplier + enemy.exp_reward_multiplier * self.BASE_EXP * rarity_bonus ) return { - "gold": max(gold, 1), + "coin": max(coin, 1), "exp": max(exp, 1), } diff --git a/src/bot/handlers/callback/battle.py b/src/bot/handlers/callback/battle.py index fb76301..835dc82 100644 --- a/src/bot/handlers/callback/battle.py +++ b/src/bot/handlers/callback/battle.py @@ -3,6 +3,6 @@ callback_battle_router = Router() -@callback_battle_router.callback_query(F.data == '') +@callback_battle_router.callback_query(F.data == 'battle') async def callback_battle(callback: CallbackQuery) -> None: pass diff --git a/src/bot/handlers/callback/inventory.py b/src/bot/handlers/callback/inventory.py index f537a7a..2c4c8ee 100644 --- a/src/bot/handlers/callback/inventory.py +++ b/src/bot/handlers/callback/inventory.py @@ -3,6 +3,6 @@ callback_inventory_router = Router() -@callback_inventory_router.callback_query(F.data == '') +@callback_inventory_router.callback_query(F.data == 'inv') async def callback_inventory(): pass \ No newline at end of file diff --git a/src/bot/handlers/callback/market.py b/src/bot/handlers/callback/market.py index 50efefd..16dcbbd 100644 --- a/src/bot/handlers/callback/market.py +++ b/src/bot/handlers/callback/market.py @@ -3,6 +3,6 @@ callback_market_router = Router() -@callback_market_router.callback_query(F.data == '') +@callback_market_router.callback_query(F.data == 'market') async def callback_market(callback: CallbackQuery) -> None: pass \ No newline at end of file diff --git a/src/bot/services/enemy.py b/src/bot/services/enemy.py index 96daaef..5bbdbeb 100644 --- a/src/bot/services/enemy.py +++ b/src/bot/services/enemy.py @@ -5,6 +5,7 @@ from random import choice, random from math import floor +# TODO : Перепроверить все class EnemyService(): def __init__(self) -> None: self.calc = EnemyCalculator() @@ -28,7 +29,7 @@ async def get_battle_stats(self, enemy: Enemies, user: Users) -> Dict: async def give_reward(self, user: Users, enemy: Enemies) -> Dict: stats = self.calc.get_rewards(enemy, user.lvl) - user.coins += stats['gold'] + user.coins += stats['coin'] user.exp += stats['exp'] leveled_up = await self._level_up(user) @@ -39,7 +40,7 @@ async def give_reward(self, user: Users, enemy: Enemies) -> Dict: await self.inventory.add(user, drop['item'], drop['quantity']) return { - "gold": stats['gold'], + "coin": stats['coin'], "exp": stats['exp'], 'level_up': leveled_up, 'drop': [f"{d['item'].name}" for d in drops] diff --git a/src/bot/services/user.py b/src/bot/services/user.py new file mode 100644 index 0000000..e7fd5f1 --- /dev/null +++ b/src/bot/services/user.py @@ -0,0 +1,5 @@ + +# TODO : Создать UserService +class UserService(): + def __init__(self) -> None: + pass \ No newline at end of file From 59d49d98c081289048afe35ef170be7279460a05 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Thu, 6 Nov 2025 22:17:12 +0500 Subject: [PATCH 15/28] Refactor user handling by introducing UserService for user creation and retrieval. Update default command and battle callback handlers to utilize UserService methods for improved clarity and consistency. Enhance TelegramTextMap with additional menu text options for better user interaction. --- src/bot/bot.py | 1 + src/bot/config.py | 14 +++++++++++++- src/bot/db/models.py | 1 + src/bot/game/logic/calculation.py | 8 ++++---- src/bot/handlers/callback/battle.py | 3 ++- src/bot/handlers/default.py | 6 +++--- src/bot/keyboards/inlines/battle.py | 8 ++++++++ src/bot/services/enemy.py | 3 +++ src/bot/services/user.py | 26 ++++++++++++++++++++++++-- 9 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/bot/bot.py b/src/bot/bot.py index 1d2c9dd..0a9dbf4 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -12,6 +12,7 @@ async def bootstrap() -> None: from src.bot.handlers.callback.battle import callback_battle_router from src.bot.handlers.callback.inventory import callback_inventory_router from src.bot.handlers.callback.market import callback_market_router + dp = Dispatcher() dp.include_routers( diff --git a/src/bot/config.py b/src/bot/config.py index a8eed4d..c830af4 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -18,4 +18,16 @@ async def GREETING_TEXT(ctx: Message | CallbackQuery): @staticmethod async def MAINMENU_TEXT(ctx: Message | CallbackQuery): - return f"{ctx.from_user.first_name}, выбери действие:" \ No newline at end of file + 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/models.py b/src/bot/db/models.py index a8f19da..59a3c33 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -14,6 +14,7 @@ class EnemyTypeEnum(str, Enum): ELITE = 'elite' BOSS = 'boss' +# TODO : Сделать модель для локаций, продаваемых предметов на рынке class Users(Model): id = fields.BigIntField(pk=True) telegram_id = fields.BigIntField(unique=True) diff --git a/src/bot/game/logic/calculation.py b/src/bot/game/logic/calculation.py index 1eb414f..cf4bbc9 100644 --- a/src/bot/game/logic/calculation.py +++ b/src/bot/game/logic/calculation.py @@ -2,8 +2,8 @@ from bot.db.models import InventoryItems, ItemRarityEnum, ItemTypeEnum, Users, Enemies, EnemyTypeEnum from bot.services.inventory import InventoryService from bot.db.schemas.items import WeaponAttributes, ArmorAttributes -from random import random, gauss, gammavariate -from math import sqrt, exp, log10, floor +from random import random, gauss, randint +from math import floor # TODO: Сделать классы для обработки общего урона-брони для используемых предметов @@ -69,13 +69,13 @@ def get_stats(self, enemy: Enemies, user: Users) -> Dict[str, int]: user_armor = ArmorCalculation(user).calculate_armor() hp = floor( - pass + 0 * enemy.health_multiplier * (1+ user_armor) * 0.85 * bonus * 0.85 ) damage = floor( - pass + 0 * enemy.damage_multiplier * (1 + user_damage) * 0.85 * bonus * 0.85 diff --git a/src/bot/handlers/callback/battle.py b/src/bot/handlers/callback/battle.py index 835dc82..38844e9 100644 --- a/src/bot/handlers/callback/battle.py +++ b/src/bot/handlers/callback/battle.py @@ -1,8 +1,9 @@ from aiogram import Router, F from aiogram.types import CallbackQuery +from ...services.user import UserService callback_battle_router = Router() @callback_battle_router.callback_query(F.data == 'battle') async def callback_battle(callback: CallbackQuery) -> None: - pass + user = UserService().get_by_telegram_id(callback.from_user.id) \ No newline at end of file diff --git a/src/bot/handlers/default.py b/src/bot/handlers/default.py index a8c0837..786ba8f 100644 --- a/src/bot/handlers/default.py +++ b/src/bot/handlers/default.py @@ -1,7 +1,7 @@ from aiogram import Router, F from aiogram.filters import Command, CommandStart from aiogram.types import Message, CallbackQuery -from ..db.models import Users +from ..services.user import UserService from ..config import TelegramTextMap from ..keyboards.inlines.default import mainmenu_keyboard @@ -9,11 +9,11 @@ @default_router.message(CommandStart()) async def command_start(message: Message) -> None: - user = await Users.get_or_create(telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name) + user = await UserService().create(telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name) 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 Users.get(telegram_id=callback.from_user.id) + user = await UserService().get_by_telegram_id(callback.from_user.id) 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 index e69de29..b4b09fc 100644 --- a/src/bot/keyboards/inlines/battle.py +++ b/src/bot/keyboards/inlines/battle.py @@ -0,0 +1,8 @@ +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +# TODO: Привязать локации из бд +async def battle_menu_keyboard() -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + + return kb.adjust(1).as_markup() diff --git a/src/bot/services/enemy.py b/src/bot/services/enemy.py index 5bbdbeb..991c5cd 100644 --- a/src/bot/services/enemy.py +++ b/src/bot/services/enemy.py @@ -1,3 +1,4 @@ +from bot.services import inventory from bot.services.inventory import InventoryService from ..db.models import Enemies, Items, Users, EnemyTypeEnum from typing import List, Dict @@ -67,4 +68,6 @@ async def _level_up(self, user: Users) -> bool: old = user.lvl while user.exp >= 65 * (user.lvl + 1): user.lvl += 1 + + await user.save() return user.lvl > old \ No newline at end of file diff --git a/src/bot/services/user.py b/src/bot/services/user.py index e7fd5f1..b95ea06 100644 --- a/src/bot/services/user.py +++ b/src/bot/services/user.py @@ -1,5 +1,27 @@ +from ..db.models import Users +from tortoise.exceptions import DoesNotExist -# TODO : Создать UserService +# TODO : Создать UserService, сделать выдачу стартового набор, сделать формулу для расчета EXP-LVL, сделать метод на добавление XP class UserService(): def __init__(self) -> None: - pass \ No newline at end of file + pass + + 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: + raise DoesNotExist("User already exist") + + return user \ No newline at end of file From 148e4781efe0de0469dd7cf7a1ea182a81b86889 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Thu, 6 Nov 2025 22:33:03 +0500 Subject: [PATCH 16/28] Integrate SafeEditMiddleware into bot message handling for improved message editing capabilities. Update callback handlers for battle, inventory, and market to utilize TelegramTextMap for dynamic menu text responses based on user interactions. --- src/bot/bot.py | 5 ++++- src/bot/handlers/callback/battle.py | 4 +++- src/bot/handlers/callback/inventory.py | 7 +++++-- src/bot/handlers/callback/market.py | 5 ++++- src/bot/middlewares/safe_edit.py | 29 ++++++++++++++++++++++++++ 5 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 src/bot/middlewares/safe_edit.py diff --git a/src/bot/bot.py b/src/bot/bot.py index 0a9dbf4..e1e0bd0 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -1,6 +1,6 @@ from aiogram import Bot, Dispatcher from .config import ConfigService - +from .middlewares.safe_edit import SafeEditMiddleware if not ConfigService.BOT_API_KEY: raise Exception("You should enter `BOT_API_KEY` in .env") @@ -22,4 +22,7 @@ async def bootstrap() -> None: callback_inventory_router, ) + dp.message.outer_middleware(SafeEditMiddleware()) + dp.callback_query.outer_middleware(SafeEditMiddleware()) + await dp.start_polling(bot) \ No newline at end of file diff --git a/src/bot/handlers/callback/battle.py b/src/bot/handlers/callback/battle.py index 38844e9..bb7b338 100644 --- a/src/bot/handlers/callback/battle.py +++ b/src/bot/handlers/callback/battle.py @@ -1,9 +1,11 @@ from aiogram import Router, F from aiogram.types import CallbackQuery from ...services.user import UserService +from ...config import TelegramTextMap callback_battle_router = Router() @callback_battle_router.callback_query(F.data == 'battle') async def callback_battle(callback: CallbackQuery) -> None: - user = UserService().get_by_telegram_id(callback.from_user.id) \ No newline at end of file + user = UserService().get_by_telegram_id(callback.from_user.id) + await callback.message.edit_text(text=await TelegramTextMap.BATTLE_MENU()) \ No newline at end of file diff --git a/src/bot/handlers/callback/inventory.py b/src/bot/handlers/callback/inventory.py index 2c4c8ee..1af3f8a 100644 --- a/src/bot/handlers/callback/inventory.py +++ b/src/bot/handlers/callback/inventory.py @@ -1,8 +1,11 @@ from aiogram import F, Router from aiogram.types import CallbackQuery +from ...config import TelegramTextMap +from ...services.user import UserService callback_inventory_router = Router() @callback_inventory_router.callback_query(F.data == 'inv') -async def callback_inventory(): - pass \ No newline at end of file +async def callback_inventory(callback: CallbackQuery): + user = UserService().get_by_telegram_id(callback.from_user.id) + await callback.message.edit_text(text=await TelegramTextMap.INVENTORY_MENU()) \ No newline at end of file diff --git a/src/bot/handlers/callback/market.py b/src/bot/handlers/callback/market.py index 16dcbbd..e13defc 100644 --- a/src/bot/handlers/callback/market.py +++ b/src/bot/handlers/callback/market.py @@ -1,8 +1,11 @@ from aiogram import Router, F from aiogram.types import CallbackQuery +from ...config import TelegramTextMap +from ...services.user import UserService callback_market_router = Router() @callback_market_router.callback_query(F.data == 'market') async def callback_market(callback: CallbackQuery) -> None: - pass \ No newline at end of file + user = UserService().get_by_telegram_id(callback.from_user.id) + await callback.message.edit_text(text=await TelegramTextMap.MARKET_MENU()) \ 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..e65125a --- /dev/null +++ b/src/bot/middlewares/safe_edit.py @@ -0,0 +1,29 @@ +from math import e +from aiogram import BaseMiddleware +from aiogram.types import Message, CallbackQuery +from aiogram.exceptions import TelegramBadRequest +from typing import Callable, Awaitable, Any +import asyncio + +class SafeEditMiddleware(BaseMiddleware): + async def __call__(self, handler: Callable[[Any, dict], Awaitable[Any]], event: Message | CallbackQuery, data: dict) -> Any: + original_message = event.answer if isinstance(event, CallbackQuery) else event.edit_text + + async def safe_edit(*args, **kwargs): + try: + return await original_message(*args, **kwargs) + except TelegramBadRequest as _ex: + if "message can't be edited" in str(_ex) or 'message is not modified' in str(_ex): + if isinstance(event, CallbackQuery): + await event.message.answer(kwargs.get("text", "Ошибка")) + else: + await event.answer("Действие устарело, напишите заново /start") + else: + raise + + if isinstance(event, CallbackQuery): + event.message.edit_text = safe_edit + else: + event.edit_text = safe_edit + + return await handler(event, data) From 708bd216d411b9231af5c06b465c282a6b44315d Mon Sep 17 00:00:00 2001 From: hell4uk Date: Mon, 24 Nov 2025 21:50:20 +0500 Subject: [PATCH 17/28] [MEGA-UPDATE] Enhance bot functionality by integrating logging for better debugging and monitoring. Update main.py to include logging configuration and set log levels for specific modules. Refactor bot initialization to utilize MemoryStorage for the Dispatcher and add LoggingMiddleware for improved message handling. Update item and enemy models to use IntEnum for better type safety and introduce new Locations and MarketItem models for expanded gameplay features. Remove unused inventory and market callback handlers to streamline codebase. --- main.py | 14 +- scripts/basedb.py | 8 + scripts/basedb/constants.py | 57 ++++++ scripts/basedb/generator.py | 159 +++++++++++++++++ src/bot/bot.py | 10 +- src/bot/db/models.py | 51 ++++-- src/bot/db/schemas/items.py | 10 +- src/bot/handlers/callback/battle.py | 3 +- src/bot/handlers/callback/inventory.py | 11 -- .../handlers/callback/inventory/__init__.py | 9 + src/bot/handlers/callback/inventory/deps.py | 11 ++ .../handlers/callback/inventory/helpers.py | 69 ++++++++ .../handlers/callback/inventory/listing.py | 72 ++++++++ src/bot/handlers/callback/inventory/menu.py | 16 ++ src/bot/handlers/callback/inventory/sell.py | 152 ++++++++++++++++ src/bot/handlers/callback/inventory/state.py | 16 ++ src/bot/handlers/callback/inventory/view.py | 34 ++++ src/bot/handlers/callback/market.py | 11 -- src/bot/handlers/callback/market/__init__.py | 10 ++ src/bot/handlers/callback/market/catalog.py | 164 ++++++++++++++++++ src/bot/handlers/callback/market/deps.py | 9 + src/bot/handlers/callback/market/filters.py | 55 ++++++ src/bot/handlers/callback/market/helpers.py | 68 ++++++++ src/bot/handlers/callback/market/inputs.py | 72 ++++++++ src/bot/handlers/callback/market/menu.py | 65 +++++++ .../handlers/callback/market/my_listings.py | 124 +++++++++++++ src/bot/handlers/callback/market/state.py | 20 +++ src/bot/keyboards/inlines/battle.py | 11 +- src/bot/keyboards/inlines/inventory.py | 71 ++++++++ src/bot/keyboards/inlines/market.py | 101 +++++++++++ src/bot/middlewares/logging.py | 32 ++++ src/bot/middlewares/safe_edit.py | 57 +++--- src/bot/services/enemy.py | 41 ++--- src/bot/services/inventory.py | 12 +- src/bot/services/item.py | 5 +- src/bot/services/market.py | 82 +++++++++ src/bot/services/user.py | 51 ++++-- 37 files changed, 1650 insertions(+), 113 deletions(-) create mode 100644 scripts/basedb.py create mode 100644 scripts/basedb/constants.py create mode 100644 scripts/basedb/generator.py delete mode 100644 src/bot/handlers/callback/inventory.py create mode 100644 src/bot/handlers/callback/inventory/__init__.py create mode 100644 src/bot/handlers/callback/inventory/deps.py create mode 100644 src/bot/handlers/callback/inventory/helpers.py create mode 100644 src/bot/handlers/callback/inventory/listing.py create mode 100644 src/bot/handlers/callback/inventory/menu.py create mode 100644 src/bot/handlers/callback/inventory/sell.py create mode 100644 src/bot/handlers/callback/inventory/state.py create mode 100644 src/bot/handlers/callback/inventory/view.py delete mode 100644 src/bot/handlers/callback/market.py create mode 100644 src/bot/handlers/callback/market/__init__.py create mode 100644 src/bot/handlers/callback/market/catalog.py create mode 100644 src/bot/handlers/callback/market/deps.py create mode 100644 src/bot/handlers/callback/market/filters.py create mode 100644 src/bot/handlers/callback/market/helpers.py create mode 100644 src/bot/handlers/callback/market/inputs.py create mode 100644 src/bot/handlers/callback/market/menu.py create mode 100644 src/bot/handlers/callback/market/my_listings.py create mode 100644 src/bot/handlers/callback/market/state.py create mode 100644 src/bot/keyboards/inlines/inventory.py create mode 100644 src/bot/middlewares/logging.py create mode 100644 src/bot/services/market.py diff --git a/main.py b/main.py index b63778a..426b5c5 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,19 @@ +import logging from src.bot.bot import bootstrap from src.bot.db.database import init_db, close_db import asyncio +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() @@ -9,6 +21,6 @@ async def main() -> None: await bootstrap() finally: await close_db() - + if __name__ == '__main__': asyncio.run(main()) \ No newline at end of file diff --git a/scripts/basedb.py b/scripts/basedb.py new file mode 100644 index 0000000..2458ef1 --- /dev/null +++ b/scripts/basedb.py @@ -0,0 +1,8 @@ +# scripts/basedb.py +import asyncio + +from basedb.generator import create_base_items + + +if __name__ == "__main__": + asyncio.run(create_base_items()) \ No newline at end of file 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..0e87958 --- /dev/null +++ b/scripts/basedb/generator.py @@ -0,0 +1,159 @@ +import sys +from pathlib import Path +from typing import Iterable, Tuple + +sys.path.append(str(Path(__file__).resolve().parents[2])) + +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 _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)") + + +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/src/bot/bot.py b/src/bot/bot.py index e1e0bd0..2dc5912 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -1,6 +1,6 @@ from aiogram import Bot, Dispatcher +from aiogram.fsm.storage.memory import MemoryStorage from .config import ConfigService -from .middlewares.safe_edit import SafeEditMiddleware if not ConfigService.BOT_API_KEY: raise Exception("You should enter `BOT_API_KEY` in .env") @@ -8,12 +8,15 @@ bot = Bot(token=ConfigService.BOT_API_KEY) async def bootstrap() -> None: + from .middlewares.safe_edit import SafeEditMiddleware + from .middlewares.logging import LoggingMiddleware + from src.bot.handlers.default import default_router from src.bot.handlers.callback.battle import callback_battle_router from src.bot.handlers.callback.inventory import callback_inventory_router from src.bot.handlers.callback.market import callback_market_router - dp = Dispatcher() + dp = Dispatcher(storage=MemoryStorage()) dp.include_routers( default_router, @@ -25,4 +28,7 @@ async def bootstrap() -> None: dp.message.outer_middleware(SafeEditMiddleware()) dp.callback_query.outer_middleware(SafeEditMiddleware()) + 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/db/models.py b/src/bot/db/models.py index 59a3c33..0bca84b 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -2,17 +2,20 @@ from tortoise.models import Model from enum import Enum -class ItemTypeEnum(str, Enum): - WEAPON = 'weapon' - ARMOR = 'armor' - -class ItemRarityEnum(str, Enum): - COMMON = 'common' - -class EnemyTypeEnum(str, Enum): - COMMON = 'common' - ELITE = 'elite' - BOSS = 'boss' +class ItemTypeEnum(int, Enum): + WEAPON = 1 + ARMOR = 2 + +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): @@ -42,9 +45,9 @@ class Items(Model): name = fields.CharField(max_length=225, null=False) description = fields.TextField(max_length=512, null=True) - type = fields.CharEnumField(ItemTypeEnum) + type = fields.IntEnumField(ItemTypeEnum) attributes = fields.JSONField(default=dict) - rarity = fields.CharEnumField(ItemRarityEnum, default=ItemRarityEnum.COMMON) + rarity = fields.IntEnumField(ItemRarityEnum, default=ItemRarityEnum.COMMON) created_at = fields.DatetimeField(auto_now_add=True) updated_at = fields.DatetimeField(auto_now=True) @@ -78,7 +81,7 @@ class Enemies(Model): name = fields.CharField(max_length=255, null=False) description = fields.TextField(max_length=512, null=True, default='') - type = fields.CharEnumField(EnemyTypeEnum, default=EnemyTypeEnum.COMMON) + type = fields.IntEnumField(EnemyTypeEnum, default=EnemyTypeEnum.COMMON) health_multiplier = fields.FloatField(default=0.5) damage_multiplier = fields.FloatField(default=0.5) @@ -101,3 +104,23 @@ class Enemies(Model): class Meta: table = 'enemies' ordering = ["name"] + +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 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) \ No newline at end of file diff --git a/src/bot/db/schemas/items.py b/src/bot/db/schemas/items.py index d0000bf..8f34249 100644 --- a/src/bot/db/schemas/items.py +++ b/src/bot/db/schemas/items.py @@ -4,19 +4,19 @@ class BaseAttributes(BaseModel): - item_level: int = Field(..., ge=1, default=1) + item_level: int = Field(..., ge=1) class WeaponAttributes(BaseAttributes): - min_damage: int = Field(..., ge=1, default=1) - max_damage: int = Field(..., ge=1, default=1) + min_damage: int = Field(..., ge=1) + max_damage: int = Field(..., ge=1) - attack_speed: float = Field(..., ge=1.0, default=1.0) + 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, default=1) + defense: int = Field(..., ge=1) health_bonus: int = Field(ge=0, default=0) diff --git a/src/bot/handlers/callback/battle.py b/src/bot/handlers/callback/battle.py index bb7b338..40762a3 100644 --- a/src/bot/handlers/callback/battle.py +++ b/src/bot/handlers/callback/battle.py @@ -8,4 +8,5 @@ @callback_battle_router.callback_query(F.data == 'battle') async def callback_battle(callback: CallbackQuery) -> None: user = UserService().get_by_telegram_id(callback.from_user.id) - await callback.message.edit_text(text=await TelegramTextMap.BATTLE_MENU()) \ No newline at end of file + await callback.message.edit_text(text=await TelegramTextMap.BATTLE_MENU(callback)) + diff --git a/src/bot/handlers/callback/inventory.py b/src/bot/handlers/callback/inventory.py deleted file mode 100644 index 1af3f8a..0000000 --- a/src/bot/handlers/callback/inventory.py +++ /dev/null @@ -1,11 +0,0 @@ -from aiogram import F, Router -from aiogram.types import CallbackQuery -from ...config import TelegramTextMap -from ...services.user import UserService - -callback_inventory_router = Router() - -@callback_inventory_router.callback_query(F.data == 'inv') -async def callback_inventory(callback: CallbackQuery): - user = UserService().get_by_telegram_id(callback.from_user.id) - await callback.message.edit_text(text=await TelegramTextMap.INVENTORY_MENU()) \ No newline at end of file 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..1ddd180 --- /dev/null +++ b/src/bot/handlers/callback/inventory/helpers.py @@ -0,0 +1,69 @@ +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: + return "weapon" if item_type == ItemTypeEnum.WEAPON else "armor" + + +def item_type_name(item_type: ItemTypeEnum) -> str: + return "оружия" if item_type == ItemTypeEnum.WEAPON else "брони" + + +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 {} + 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 + + 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 "—" + description = item.description or "Описание отсутствует." + + parts = [ + f"{item.name}:", + "", + f"Описание: {description}", + "", + f"Средний урон: {avg_damage_text}", + f"Скорость аттаки: {attack_speed_text}", + "", + "Продать — выставить предмет на рынок и получить монеты.", + ] + 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.py b/src/bot/handlers/callback/market.py deleted file mode 100644 index e13defc..0000000 --- a/src/bot/handlers/callback/market.py +++ /dev/null @@ -1,11 +0,0 @@ -from aiogram import Router, F -from aiogram.types import CallbackQuery -from ...config import TelegramTextMap -from ...services.user import UserService - -callback_market_router = Router() - -@callback_market_router.callback_query(F.data == 'market') -async def callback_market(callback: CallbackQuery) -> None: - user = UserService().get_by_telegram_id(callback.from_user.id) - await callback.message.edit_text(text=await TelegramTextMap.MARKET_MENU()) \ No newline at end of file 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..116fadb --- /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": None, + "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..5b26784 --- /dev/null +++ b/src/bot/handlers/callback/market/helpers.py @@ -0,0 +1,68 @@ +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 {} + 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 "—" + description = item.description or "Описание отсутствует." + + return ( + f"{item.name}:\n\n" + f"Описание: {description}\n\n" + f"Средний урон: {avg_damage_text}\n" + f"Скорость атаки: {attack_speed_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/keyboards/inlines/battle.py b/src/bot/keyboards/inlines/battle.py index b4b09fc..ae972d6 100644 --- a/src/bot/keyboards/inlines/battle.py +++ b/src/bot/keyboards/inlines/battle.py @@ -1,8 +1,15 @@ from aiogram.types import InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder +from ...db.models import Locations -# TODO: Привязать локации из бд async def battle_menu_keyboard() -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() - return kb.adjust(1).as_markup() + locations = await Locations.all() + + for loc in locations: + kb.button(text=loc.name, callback_data=f"battle_loc_{loc.id}") + + kb.button(text="Назад", callback_data="mainmenu") + + 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..e011eac --- /dev/null +++ b/src/bot/keyboards/inlines/inventory.py @@ -0,0 +1,71 @@ +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="mainmenu") + kb.adjust(2, 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() + 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 index e69de29..95991c4 100644 --- a/src/bot/keyboards/inlines/market.py +++ b/src/bot/keyboards/inlines/market.py @@ -0,0 +1,101 @@ +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="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, 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/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 index e65125a..e6bf74e 100644 --- a/src/bot/middlewares/safe_edit.py +++ b/src/bot/middlewares/safe_edit.py @@ -1,29 +1,44 @@ -from math import e +# src/bot/middlewares/safe_edit.py from aiogram import BaseMiddleware -from aiogram.types import Message, CallbackQuery +from aiogram.types import TelegramObject, CallbackQuery, Message from aiogram.exceptions import TelegramBadRequest -from typing import Callable, Awaitable, Any -import asyncio +from typing import Callable, Dict, Any, Awaitable +import logging class SafeEditMiddleware(BaseMiddleware): - async def __call__(self, handler: Callable[[Any, dict], Awaitable[Any]], event: Message | CallbackQuery, data: dict) -> Any: - original_message = event.answer if isinstance(event, CallbackQuery) else event.edit_text + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + bot = data['bot'] - async def safe_edit(*args, **kwargs): + async def safe_edit_message(*args, **kwargs) -> Any: try: - return await original_message(*args, **kwargs) - except TelegramBadRequest as _ex: - if "message can't be edited" in str(_ex) or 'message is not modified' in str(_ex): - if isinstance(event, CallbackQuery): - await event.message.answer(kwargs.get("text", "Ошибка")) + if isinstance(event, CallbackQuery): + if event.message: + return await event.message.edit_text(*args, **kwargs) + elif event.inline_message_id: + return await bot.edit_message_text(inline_message_id=event.inline_message_id, *args, **kwargs) else: - await event.answer("Действие устарело, напишите заново /start") + raise ValueError("No message to edit in CallbackQuery") + + elif isinstance(event, Message): + return await event.edit_text(*args, **kwargs) + else: - raise - - if isinstance(event, CallbackQuery): - event.message.edit_text = safe_edit - else: - event.edit_text = safe_edit - - return await handler(event, data) + raise ValueError(f"Unsupported event type for safe_edit: {type(event)}") + + except TelegramBadRequest as e: + if "message is not modified" in str(e).lower(): + return None + else: + raise + + except Exception as e: + raise + + data['safe_edit_message'] = safe_edit_message + + return await handler(event, data) \ No newline at end of file diff --git a/src/bot/services/enemy.py b/src/bot/services/enemy.py index 991c5cd..1bc7e7f 100644 --- a/src/bot/services/enemy.py +++ b/src/bot/services/enemy.py @@ -14,60 +14,53 @@ def __init__(self) -> None: async def spawn(self, user: Users) -> Enemies: level = max(1, user.lvl) - rarity = self._get_rarity(level) - candidates = await Enemies.filter(type=rarity, is_active=True).all() - if not candidates: - raise ValueError("Unsupported users statistics") - + raise ValueError("No enemies available for this level") return choice(candidates) async def get_battle_stats(self, enemy: Enemies, user: Users) -> Dict: - return self.calc.get_stats(enemy, user.lvl) - + return await self.calc.get_stats(enemy, user) # await + async def give_reward(self, user: Users, enemy: Enemies) -> Dict: stats = self.calc.get_rewards(enemy, user.lvl) - user.coins += stats['coin'] user.exp += stats['exp'] - leveled_up = await self._level_up(user) - await user.save() - drops = await self._roll_drops(enemy) for drop in drops: await self.inventory.add(user, drop['item'], drop['quantity']) - + await user.save() return { - "coin": stats['coin'], - "exp": stats['exp'], - 'level_up': leveled_up, - 'drop': [f"{d['item'].name}" for d in drops] + "coin": stats['coin'], "exp": stats['exp'], + 'level_up': leveled_up, 'drop': [f"{d['item'].name} x{d['quantity']}" for d in drops] } def _get_rarity(self, level: int) -> EnemyTypeEnum: if level >= 20: return choice([EnemyTypeEnum.BOSS] + [EnemyTypeEnum.ELITE] * 3 + [EnemyTypeEnum.COMMON] * 4) - if level >= 8: + elif level >= 8: return choice([EnemyTypeEnum.ELITE] * 2 + [EnemyTypeEnum.COMMON] * 8) return EnemyTypeEnum.COMMON async def _roll_drops(self, enemy: Enemies) -> List[Dict]: - if random() * 100 > enemy.drop_chance: + chance = self.calc.get_drop_chance(enemy, 1) # player_level TODO: from user + if random() * 100 > chance: return [] - drops = [] - async for item in enemy.drop_items.all(): + 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: - old = user.lvl - while user.exp >= 65 * (user.lvl + 1): + 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) - await user.save() - return user.lvl > old \ No newline at end of file + 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 index 3b5df55..1d6420f 100644 --- a/src/bot/services/inventory.py +++ b/src/bot/services/inventory.py @@ -1,13 +1,13 @@ 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 +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() + self.item_service = ItemService(SCHEMAS_MAP) # ? --- CRUD методы --- async def add(self, user: Users, item: Items, quantity: int = 1, auto_create: bool = True) -> InventoryItems: @@ -88,4 +88,10 @@ async def get_equipped_item(self, user: Users, item_type: ItemTypeEnum) -> Optio 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 index 7573ffb..6bf1bf2 100644 --- a/src/bot/services/item.py +++ b/src/bot/services/item.py @@ -1,8 +1,7 @@ from typing import Optional, Type, List, Dict -from tortoise.exceptions import DoesNotExist -from bot.db.models import Items, InventoryItems, ItemTypeEnum, ItemEnum, ItemRarityEnum -from bot.db.schemas.items import ArmorAttributes, BaseAttributes, WeaponAttributes +from ...bot.db.models import Items, ItemTypeEnum, ItemRarityEnum +from ...bot.db.schemas.items import ArmorAttributes, BaseAttributes, WeaponAttributes from pydantic import ValidationError SCHEMAS_MAP = { diff --git a/src/bot/services/market.py b/src/bot/services/market.py new file mode 100644 index 0000000..758779f --- /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: + qs = qs.filter(rarity=rarity) + if search: + 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 index b95ea06..3e2266f 100644 --- a/src/bot/services/user.py +++ b/src/bot/services/user.py @@ -1,27 +1,48 @@ -from ..db.models import Users +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: - pass + 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, - },) - + 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: - raise DoesNotExist("User already exist") - - return user \ No newline at end of file + 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 From 8e67df4fd7da3cdf6608f0b7ccbfc34a1511241d Mon Sep 17 00:00:00 2001 From: hell4uk Date: Fri, 28 Nov 2025 23:56:18 +0500 Subject: [PATCH 18/28] Refactor bot middleware and handlers for improved functionality. Replace SafeEditMiddleware with SafePatchMiddleware for enhanced message editing capabilities. Update inventory and market item description formatting to include defense and health attributes. Set default minimum price filter to 0 in market filters. Remove unused battle callback handler to streamline codebase. --- src/bot/bot.py | 6 +- src/bot/handlers/callback/battle.py | 12 -- .../handlers/callback/inventory/helpers.py | 42 +++++-- src/bot/handlers/callback/market/filters.py | 2 +- src/bot/handlers/callback/market/helpers.py | 51 ++++++--- src/bot/middlewares/safe_edit.py | 104 ++++++++++++------ 6 files changed, 138 insertions(+), 79 deletions(-) delete mode 100644 src/bot/handlers/callback/battle.py diff --git a/src/bot/bot.py b/src/bot/bot.py index 2dc5912..7bca1d5 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -8,7 +8,7 @@ bot = Bot(token=ConfigService.BOT_API_KEY) async def bootstrap() -> None: - from .middlewares.safe_edit import SafeEditMiddleware + from .middlewares.safe_edit import SafePatchMiddleware from .middlewares.logging import LoggingMiddleware from src.bot.handlers.default import default_router @@ -25,8 +25,8 @@ async def bootstrap() -> None: callback_inventory_router, ) - dp.message.outer_middleware(SafeEditMiddleware()) - dp.callback_query.outer_middleware(SafeEditMiddleware()) + dp.message.outer_middleware(SafePatchMiddleware()) + dp.callback_query.outer_middleware(SafePatchMiddleware()) dp.message.outer_middleware(LoggingMiddleware()) dp.callback_query.outer_middleware(LoggingMiddleware()) diff --git a/src/bot/handlers/callback/battle.py b/src/bot/handlers/callback/battle.py deleted file mode 100644 index 40762a3..0000000 --- a/src/bot/handlers/callback/battle.py +++ /dev/null @@ -1,12 +0,0 @@ -from aiogram import Router, F -from aiogram.types import CallbackQuery -from ...services.user import UserService -from ...config import TelegramTextMap - -callback_battle_router = Router() - -@callback_battle_router.callback_query(F.data == 'battle') -async def callback_battle(callback: CallbackQuery) -> None: - user = UserService().get_by_telegram_id(callback.from_user.id) - await callback.message.edit_text(text=await TelegramTextMap.BATTLE_MENU(callback)) - diff --git a/src/bot/handlers/callback/inventory/helpers.py b/src/bot/handlers/callback/inventory/helpers.py index 1ddd180..385ee47 100644 --- a/src/bot/handlers/callback/inventory/helpers.py +++ b/src/bot/handlers/callback/inventory/helpers.py @@ -26,16 +26,6 @@ async def collect_items(user, item_type: ItemTypeEnum, inv_service: InventorySer def format_item_detail_text(inv_item) -> str: item = inv_item.item attributes = item.attributes or {} - 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 - - 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 "—" description = item.description or "Описание отсутствует." parts = [ @@ -43,11 +33,39 @@ def format_item_detail_text(inv_item) -> str: "", f"Описание: {description}", "", - f"Средний урон: {avg_damage_text}", - f"Скорость аттаки: {attack_speed_text}", + ] + + if "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 += [ "", "Продать — выставить предмет на рынок и получить монеты.", ] + return "\n".join(parts) diff --git a/src/bot/handlers/callback/market/filters.py b/src/bot/handlers/callback/market/filters.py index 116fadb..650ee0d 100644 --- a/src/bot/handlers/callback/market/filters.py +++ b/src/bot/handlers/callback/market/filters.py @@ -9,7 +9,7 @@ DEFAULT_FILTERS: Dict[str, FilterValue] = { "search": "", "rarity": "all", - "min_price": None, + "min_price": 0, "max_price": None, } diff --git a/src/bot/handlers/callback/market/helpers.py b/src/bot/handlers/callback/market/helpers.py index 5b26784..1a959d1 100644 --- a/src/bot/handlers/callback/market/helpers.py +++ b/src/bot/handlers/callback/market/helpers.py @@ -47,22 +47,41 @@ def next_rarity_slug(current: str) -> str: def format_item_description(item: Items) -> str: attrs = item.attributes or {} - 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 "—" description = item.description or "Описание отсутствует." - return ( - f"{item.name}:\n\n" - f"Описание: {description}\n\n" - f"Средний урон: {avg_damage_text}\n" - f"Скорость атаки: {attack_speed_text}" - ) + 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/middlewares/safe_edit.py b/src/bot/middlewares/safe_edit.py index e6bf74e..6c571b6 100644 --- a/src/bot/middlewares/safe_edit.py +++ b/src/bot/middlewares/safe_edit.py @@ -1,44 +1,78 @@ -# src/bot/middlewares/safe_edit.py -from aiogram import BaseMiddleware -from aiogram.types import TelegramObject, CallbackQuery, Message -from aiogram.exceptions import TelegramBadRequest -from typing import Callable, Dict, Any, Awaitable 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 -class SafeEditMiddleware(BaseMiddleware): - async def __call__( - self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], - event: TelegramObject, - data: Dict[str, Any] - ) -> Any: - bot = data['bot'] + 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 - async def safe_edit_message(*args, **kwargs) -> Any: try: - if isinstance(event, CallbackQuery): - if event.message: - return await event.message.edit_text(*args, **kwargs) - elif event.inline_message_id: - return await bot.edit_message_text(inline_message_id=event.inline_message_id, *args, **kwargs) - else: - raise ValueError("No message to edit in CallbackQuery") - - elif isinstance(event, Message): - return await event.edit_text(*args, **kwargs) - - else: - raise ValueError(f"Unsupported event type for safe_edit: {type(event)}") - + 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 - else: - raise - - except Exception as e: + return None + if "can't" in str(e).lower() or "not found" in str(e).lower(): + return None raise - data['safe_edit_message'] = safe_edit_message + 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 - return await handler(event, data) \ No newline at end of file + logging.info("SafePatchMiddleware: monkey patch applied") \ No newline at end of file From 70785c1433df52b27edcbcfe9e4897db16520ce6 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Sat, 29 Nov 2025 01:13:06 +0500 Subject: [PATCH 19/28] Enhance user and enemy models by adding new fields for tracking wins, losses, and ELO ratings. Update ordering in the Enemies and Locations models for consistency. Implement string representation methods for better debugging output. Add a new button for statistics in the main menu keyboard. --- src/bot/db/models.py | 32 +++++++++++++++++-- src/bot/db/schemas/items.py | 2 +- src/bot/handlers/callback/battle/__init__.py | 4 +++ src/bot/handlers/callback/battle/deps.py | 11 +++++++ .../handlers/callback/multiplayer/__init__.py | 4 +++ src/bot/handlers/callback/multiplayer/deps.py | 11 +++++++ .../handlers/callback/statistics/__init__.py | 4 +++ src/bot/handlers/callback/statistics/deps.py | 9 ++++++ src/bot/handlers/callback/statistics/view.py | 16 ++++++++++ src/bot/keyboards/inlines/default.py | 1 + src/bot/keyboards/inlines/statistics.py | 10 ++++++ 11 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 src/bot/handlers/callback/battle/__init__.py create mode 100644 src/bot/handlers/callback/battle/deps.py create mode 100644 src/bot/handlers/callback/multiplayer/__init__.py create mode 100644 src/bot/handlers/callback/multiplayer/deps.py create mode 100644 src/bot/handlers/callback/statistics/__init__.py create mode 100644 src/bot/handlers/callback/statistics/deps.py create mode 100644 src/bot/handlers/callback/statistics/view.py create mode 100644 src/bot/keyboards/inlines/statistics.py diff --git a/src/bot/db/models.py b/src/bot/db/models.py index 0bca84b..31107f6 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -29,7 +29,15 @@ class Users(Model): 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_loses = fields.IntField(default=0) + elo = fields.IntField(default=0) + created_at = fields.DatetimeField(auto_now_add=True) updated_at = fields.DatetimeField(auto_now=True) @@ -83,6 +91,10 @@ class Enemies(Model): type = fields.IntEnumField(EnemyTypeEnum, default=EnemyTypeEnum.COMMON) + # TODO: Make it + #all_locations = + #preferred_locations = + health_multiplier = fields.FloatField(default=0.5) damage_multiplier = fields.FloatField(default=0.5) @@ -103,8 +115,10 @@ class Enemies(Model): class Meta: table = 'enemies' - ordering = ["name"] + ordering = ['id', "name"] + def __str__(self) -> str: + return f"" class Locations(Model): id = fields.BigIntField(pk=True) name = fields.CharField(max_length=255) @@ -114,6 +128,13 @@ class Locations(Model): 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) @@ -123,4 +144,11 @@ class MarketItem(Model): price = fields.IntField() created_at = fields.DatetimeField(auto_now_add=True) - updated_at = fields.DatetimeField(auto_now=True) \ No newline at end of file + 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 index 8f34249..50ad319 100644 --- a/src/bot/db/schemas/items.py +++ b/src/bot/db/schemas/items.py @@ -19,4 +19,4 @@ class WeaponAttributes(BaseAttributes): class ArmorAttributes(BaseAttributes): defense: int = Field(..., ge=1) - health_bonus: int = Field(ge=0, default=0) + health_bonus: int = Field(ge=0, default=0) \ No newline at end of file diff --git a/src/bot/handlers/callback/battle/__init__.py b/src/bot/handlers/callback/battle/__init__.py new file mode 100644 index 0000000..08c7ba5 --- /dev/null +++ b/src/bot/handlers/callback/battle/__init__.py @@ -0,0 +1,4 @@ +from .deps import battle_router as callback_battle_router + + +__all__ = ('callback_battle_router',) \ No newline at end of file diff --git a/src/bot/handlers/callback/battle/deps.py b/src/bot/handlers/callback/battle/deps.py new file mode 100644 index 0000000..f029eab --- /dev/null +++ b/src/bot/handlers/callback/battle/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 + +battle_router = Router() + +user_service = UserService() +inventory_service = InventoryService() +enemy_service = EnemyService() \ No newline at end of file diff --git a/src/bot/handlers/callback/multiplayer/__init__.py b/src/bot/handlers/callback/multiplayer/__init__.py new file mode 100644 index 0000000..3504e27 --- /dev/null +++ b/src/bot/handlers/callback/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/multiplayer/deps.py b/src/bot/handlers/callback/multiplayer/deps.py new file mode 100644 index 0000000..be8136c --- /dev/null +++ b/src/bot/handlers/callback/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/statistics/__init__.py b/src/bot/handlers/callback/statistics/__init__.py new file mode 100644 index 0000000..4ca338c --- /dev/null +++ b/src/bot/handlers/callback/statistics/__init__.py @@ -0,0 +1,4 @@ +from .deps import statistics_router as callback_statistics_router + + +__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..c33e953 --- /dev/null +++ b/src/bot/handlers/callback/statistics/view.py @@ -0,0 +1,16 @@ +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 + +@statistics_router.callback_query(F.data == 'stats') +async def view_user_statistics(callback: CallbackQuery): + user = user_service.get_by_telegram_id(callback.from_user.id) + + equipped_sword = inventory_service.get_equipped_item(user, ItemTypeEnum.WEAPON) + equipped_armor = inventory_service.get_equipped_item(user, ItemTypeEnum.ARMOR) + + message = f"{callback.from_user.first_name}, ваша статистика:\nМонет: {user.coins}\n" + + await callback.message.edit_text('', reply_markup=await get_statistic_keyboard()) \ No newline at end of file diff --git a/src/bot/keyboards/inlines/default.py b/src/bot/keyboards/inlines/default.py index a63a0f5..9125a98 100644 --- a/src/bot/keyboards/inlines/default.py +++ b/src/bot/keyboards/inlines/default.py @@ -9,5 +9,6 @@ async def mainmenu_keyboard() -> InlineKeyboardMarkup: kb.button(text='Начать сражение', callback_data='battle') 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/statistics.py b/src/bot/keyboards/inlines/statistics.py new file mode 100644 index 0000000..b738c9c --- /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('Назад', callback_data='mainmenu') + + return kb.adjust(1).as_markup() \ No newline at end of file From 4c207b7febbda06f94ab9dba96b5d1098c709ccd Mon Sep 17 00:00:00 2001 From: hell4uk Date: Sat, 29 Nov 2025 01:25:10 +0500 Subject: [PATCH 20/28] Enhance user statistics display in callback handler by adding detailed information including level, experience, wins, losses, and equipped items. Update message formatting for improved readability. --- src/bot/handlers/callback/statistics/view.py | 21 ++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/bot/handlers/callback/statistics/view.py b/src/bot/handlers/callback/statistics/view.py index c33e953..9c0f707 100644 --- a/src/bot/handlers/callback/statistics/view.py +++ b/src/bot/handlers/callback/statistics/view.py @@ -1,3 +1,4 @@ +from math import e from aiogram.types import CallbackQuery from aiogram import F from .deps import statistics_router, user_service, inventory_service @@ -11,6 +12,22 @@ async def view_user_statistics(callback: CallbackQuery): equipped_sword = inventory_service.get_equipped_item(user, ItemTypeEnum.WEAPON) equipped_armor = inventory_service.get_equipped_item(user, ItemTypeEnum.ARMOR) - message = f"{callback.from_user.first_name}, ваша статистика:\nМонет: {user.coins}\n" + 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_wins} + =========================== + Ваше оружие: {equipped_sword.rarity} | {equipped_sword.name} + Ваша броня: {equipped_armor.rarity} | {equipped_armor.name} + """ - await callback.message.edit_text('', reply_markup=await get_statistic_keyboard()) \ No newline at end of file + await callback.message.edit_text(message, reply_markup=await get_statistic_keyboard()) \ No newline at end of file From 350166c759881c27abe7e4a7eb978c6532170cfc Mon Sep 17 00:00:00 2001 From: hell4uk Date: Sun, 30 Nov 2025 17:07:36 +0500 Subject: [PATCH 21/28] Implement Windows compatibility for asyncio event loop in main.py. Refactor damage and armor calculations in calculation.py to use dictionary access for weapon and armor attributes. Update get_stats method in EnemyCalculator to be asynchronous, ensuring proper handling of calculations. --- main.py | 4 ++++ src/bot/game/logic/calculation.py | 26 ++++++++++++------------ src/bot/services/cache.py | 33 +++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 src/bot/services/cache.py diff --git a/main.py b/main.py index 426b5c5..a8b5acb 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,10 @@ 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, diff --git a/src/bot/game/logic/calculation.py b/src/bot/game/logic/calculation.py index cf4bbc9..46d59f7 100644 --- a/src/bot/game/logic/calculation.py +++ b/src/bot/game/logic/calculation.py @@ -23,12 +23,12 @@ async def calculate_damage(self) -> int: weapon_attributes = equipped_item.item.attributes - damage = gauss(weapon_attributes.min_damage, weapon_attributes.max_damage) - if random() < weapon_attributes.critical_chance: - damage *= weapon_attributes.critical_multiplier + damage = gauss(weapon_attributes.get("min_damage"), weapon_attributes.get("max_damage")) + if random() < weapon_attributes.get("critical_chance"): + damage *= weapon_attributes.get("critical_multiplier") - damage *= weapon_attributes.attack_speed - damage *= 1 + (weapon_attributes.item_level * 0.02) + damage *= weapon_attributes.get("attack_speed") + damage *= 1 + (weapon_attributes.get("item_level") * 0.02) damage = floor(damage) return damage @@ -45,9 +45,9 @@ async def calculate_armor(self) -> float: armor_attributes = equipped_item.item.attributes - armor = armor_attributes.defense - armor *= 1 + (armor_attributes.health_bonus * random()) - armor *= 1 + (armor_attributes.item_level * 0.02) + armor = armor_attributes.get("defense") + armor *= 1 + (armor_attributes.get("health_bonus") * random()) + armor *= 1 + (armor_attributes.get("item_level") * 0.02) armor = floor(armor) return armor @@ -62,11 +62,11 @@ class EnemyCalculator: EnemyTypeEnum.BOSS: 8.0, } - def get_stats(self, enemy: Enemies, user: Users) -> Dict[str, int]: + async def get_stats(self, enemy: Enemies, user: Users) -> Dict[str, int]: bonus = self.RARITY_BONUS.get(enemy.type, 1.0) - user_damage = DamageCalculation(user).calculate_damage() - user_armor = ArmorCalculation(user).calculate_armor() + user_damage = await DamageCalculation(user).calculate_damage() + user_armor = await ArmorCalculation(user).calculate_armor() hp = floor( 0 @@ -93,7 +93,7 @@ def get_rewards(self, enemy: Enemies, player_level: int) -> Dict[str, int]: enemy.coin_reward_multiplier * self.BASE_COIN * rarity_bonus - ) + ) exp = floor( enemy.exp_reward_multiplier * self.BASE_EXP @@ -104,7 +104,7 @@ def get_rewards(self, enemy: Enemies, player_level: int) -> Dict[str, int]: "coin": max(coin, 1), "exp": max(exp, 1), } - + def get_drop_chance(self, enemy: Enemies, player_level: int) -> float: 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/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 From 3f65b9262beac4b0c838007acf823787b2dc95aa Mon Sep 17 00:00:00 2001 From: hell4uk Date: Sun, 7 Dec 2025 01:04:24 +0500 Subject: [PATCH 22/28] Enhance main.py to handle InterruptedError gracefully, providing user feedback when the Telegram bot is closed. Remove unused basedb.py and related battle callback handlers to streamline the codebase. Introduce new statistics callback handler and update user statistics display with equipped item details. Refactor database sequence reset logic in generator.py to prevent ID conflicts. Update battle and statistics keyboards for improved user interaction. --- main.py | 4 ++ requirements.txt | Bin 1316 -> 1270 bytes scripts/basedb.py | 8 --- scripts/basedb/__init__.py | 2 + scripts/basedb/generator.py | 19 ++++++ scripts/init_basedb.py | 16 ++++++ src/bot/bot.py | 2 + src/bot/game/logic/calculation.py | 6 +- src/bot/handlers/callback/battle/__init__.py | 4 -- src/bot/handlers/callback/battle/default.py | 14 +++++ src/bot/handlers/callback/battle/deps.py | 11 ---- .../{ => battle}/multiplayer/__init__.py | 0 .../callback/{ => battle}/multiplayer/deps.py | 6 +- .../callback/battle/singleplayer/__init__.py | 4 ++ .../callback/battle/singleplayer/deps.py | 11 ++++ .../handlers/callback/statistics/__init__.py | 2 +- src/bot/handlers/callback/statistics/view.py | 54 +++++++++++------- src/bot/keyboards/inlines/battle.py | 22 ++++++- src/bot/keyboards/inlines/statistics.py | 2 +- src/bot/services/enemy.py | 3 +- 20 files changed, 133 insertions(+), 57 deletions(-) delete mode 100644 scripts/basedb.py create mode 100644 scripts/basedb/__init__.py create mode 100644 scripts/init_basedb.py delete mode 100644 src/bot/handlers/callback/battle/__init__.py create mode 100644 src/bot/handlers/callback/battle/default.py delete mode 100644 src/bot/handlers/callback/battle/deps.py rename src/bot/handlers/callback/{ => battle}/multiplayer/__init__.py (100%) rename src/bot/handlers/callback/{ => battle}/multiplayer/deps.py (52%) create mode 100644 src/bot/handlers/callback/battle/singleplayer/__init__.py create mode 100644 src/bot/handlers/callback/battle/singleplayer/deps.py diff --git a/main.py b/main.py index a8b5acb..8d76597 100644 --- a/main.py +++ b/main.py @@ -23,6 +23,10 @@ async def main() -> None: await init_db() try: await bootstrap() + + except InterruptedError: + print("Telegram bot was closed!") + finally: await close_db() diff --git a/requirements.txt b/requirements.txt index 2a8097fd3d53384d25ddb365809b57309eb56941..1b103c934bf63bdcfa5f6392bb86d9ad0ba28d94 100644 GIT binary patch delta 12 TcmZ3&^^J2w2=nG9=0%JEAj$-g delta 50 zcmeyyxrA#&2(xMmLn1>SLkUABLo!1=5a%-#F{Cos0-+Iu9)mFu>M ItemRarityEnum: 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: @@ -70,6 +86,9 @@ async def _ensure_default_items(): 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): 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/src/bot/bot.py b/src/bot/bot.py index 7bca1d5..abb0806 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -15,6 +15,7 @@ async def bootstrap() -> None: from src.bot.handlers.callback.battle import callback_battle_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 dp = Dispatcher(storage=MemoryStorage()) @@ -23,6 +24,7 @@ async def bootstrap() -> None: callback_market_router, callback_battle_router, callback_inventory_router, + callback_statistics_router, ) dp.message.outer_middleware(SafePatchMiddleware()) diff --git a/src/bot/game/logic/calculation.py b/src/bot/game/logic/calculation.py index 46d59f7..541cfbd 100644 --- a/src/bot/game/logic/calculation.py +++ b/src/bot/game/logic/calculation.py @@ -1,7 +1,7 @@ from typing import Dict -from bot.db.models import InventoryItems, ItemRarityEnum, ItemTypeEnum, Users, Enemies, EnemyTypeEnum -from bot.services.inventory import InventoryService -from bot.db.schemas.items import WeaponAttributes, ArmorAttributes +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 diff --git a/src/bot/handlers/callback/battle/__init__.py b/src/bot/handlers/callback/battle/__init__.py deleted file mode 100644 index 08c7ba5..0000000 --- a/src/bot/handlers/callback/battle/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .deps import battle_router as callback_battle_router - - -__all__ = ('callback_battle_router',) \ No newline at end of file diff --git a/src/bot/handlers/callback/battle/default.py b/src/bot/handlers/callback/battle/default.py new file mode 100644 index 0000000..c0b6d80 --- /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 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/deps.py b/src/bot/handlers/callback/battle/deps.py deleted file mode 100644 index f029eab..0000000 --- a/src/bot/handlers/callback/battle/deps.py +++ /dev/null @@ -1,11 +0,0 @@ -from aiogram import Router - -from ....services.enemy import EnemyService -from ....services.inventory import InventoryService -from ....services.user import UserService - -battle_router = Router() - -user_service = UserService() -inventory_service = InventoryService() -enemy_service = EnemyService() \ No newline at end of file diff --git a/src/bot/handlers/callback/multiplayer/__init__.py b/src/bot/handlers/callback/battle/multiplayer/__init__.py similarity index 100% rename from src/bot/handlers/callback/multiplayer/__init__.py rename to src/bot/handlers/callback/battle/multiplayer/__init__.py diff --git a/src/bot/handlers/callback/multiplayer/deps.py b/src/bot/handlers/callback/battle/multiplayer/deps.py similarity index 52% rename from src/bot/handlers/callback/multiplayer/deps.py rename to src/bot/handlers/callback/battle/multiplayer/deps.py index be8136c..aa81d80 100644 --- a/src/bot/handlers/callback/multiplayer/deps.py +++ b/src/bot/handlers/callback/battle/multiplayer/deps.py @@ -1,8 +1,8 @@ from aiogram import Router -from ....services.enemy import EnemyService -from ....services.inventory import InventoryService -from ....services.user import UserService +from .....services.enemy import EnemyService +from .....services.inventory import InventoryService +from .....services.user import UserService multiplayer_router = Router() 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..80c74cc --- /dev/null +++ b/src/bot/handlers/callback/battle/singleplayer/__init__.py @@ -0,0 +1,4 @@ +from .deps import singleplayer_router as callback_singleplayer_router + + +__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..9749c13 --- /dev/null +++ b/src/bot/handlers/callback/battle/singleplayer/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 + +singleplayer_router = Router() + +user_service = UserService() +inventory_service = InventoryService() +enemy_service = EnemyService() \ No newline at end of file diff --git a/src/bot/handlers/callback/statistics/__init__.py b/src/bot/handlers/callback/statistics/__init__.py index 4ca338c..93fc3e2 100644 --- a/src/bot/handlers/callback/statistics/__init__.py +++ b/src/bot/handlers/callback/statistics/__init__.py @@ -1,4 +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/view.py b/src/bot/handlers/callback/statistics/view.py index 9c0f707..00a1e4a 100644 --- a/src/bot/handlers/callback/statistics/view.py +++ b/src/bot/handlers/callback/statistics/view.py @@ -1,33 +1,43 @@ -from math import e 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 +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 = user_service.get_by_telegram_id(callback.from_user.id) + 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) - equipped_sword = inventory_service.get_equipped_item(user, ItemTypeEnum.WEAPON) - equipped_armor = 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_wins} - =========================== - Ваше оружие: {equipped_sword.rarity} | {equipped_sword.name} - Ваша броня: {equipped_armor.rarity} | {equipped_armor.name} - """ +{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()) \ No newline at end of file + 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/keyboards/inlines/battle.py b/src/bot/keyboards/inlines/battle.py index ae972d6..5951b2e 100644 --- a/src/bot/keyboards/inlines/battle.py +++ b/src/bot/keyboards/inlines/battle.py @@ -2,7 +2,17 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from ...db.models import Locations -async def battle_menu_keyboard() -> InlineKeyboardMarkup: +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() @@ -10,6 +20,14 @@ async def battle_menu_keyboard() -> InlineKeyboardMarkup: for loc in locations: kb.button(text=loc.name, callback_data=f"battle_loc_{loc.id}") - kb.button(text="Назад", callback_data="mainmenu") + 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() \ No newline at end of file diff --git a/src/bot/keyboards/inlines/statistics.py b/src/bot/keyboards/inlines/statistics.py index b738c9c..b4ab48c 100644 --- a/src/bot/keyboards/inlines/statistics.py +++ b/src/bot/keyboards/inlines/statistics.py @@ -5,6 +5,6 @@ async def get_statistic_keyboard() -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() - kb.button('Назад', callback_data='mainmenu') + kb.button(text='Назад', callback_data='mainmenu') return kb.adjust(1).as_markup() \ No newline at end of file diff --git a/src/bot/services/enemy.py b/src/bot/services/enemy.py index 1bc7e7f..87725db 100644 --- a/src/bot/services/enemy.py +++ b/src/bot/services/enemy.py @@ -1,5 +1,4 @@ -from bot.services import inventory -from bot.services.inventory import InventoryService +from .inventory import InventoryService from ..db.models import Enemies, Items, Users, EnemyTypeEnum from typing import List, Dict from ..game.logic.calculation import EnemyCalculator From 916b5520d03fa5b672f9a882bd8e2538d435e6a2 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Sun, 7 Dec 2025 02:06:46 +0500 Subject: [PATCH 23/28] Refactor enemy spawning logic in EnemyService to include location filtering, ensuring enemies are spawned based on the user's current location. Update calculation methods in DamageCalculation and ArmorCalculation to accept user parameters directly, enhancing flexibility. Introduce Locations model with string representation for improved debugging. Clean up unused code and comments in models.py. --- scripts/gamedb/__init__.py | 2 + scripts/gamedb/constants.py | 211 ++++++++++++++++++ scripts/gamedb/generator.py | 122 ++++++++++ scripts/init_gamedb.py | 16 ++ src/bot/db/models.py | 8 +- src/bot/game/logic/calculation.py | 17 +- .../callback/battle/singleplayer/deps.py | 8 +- .../callback/battle/singleplayer/helper.py | 35 +++ .../callback/battle/singleplayer/view.py | 12 + src/bot/services/enemy.py | 13 +- 10 files changed, 424 insertions(+), 20 deletions(-) create mode 100644 scripts/gamedb/__init__.py create mode 100644 scripts/gamedb/constants.py create mode 100644 scripts/gamedb/generator.py create mode 100644 scripts/init_gamedb.py create mode 100644 src/bot/handlers/callback/battle/singleplayer/helper.py create mode 100644 src/bot/handlers/callback/battle/singleplayer/view.py 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_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/src/bot/db/models.py b/src/bot/db/models.py index 31107f6..c728496 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -91,10 +91,6 @@ class Enemies(Model): type = fields.IntEnumField(EnemyTypeEnum, default=EnemyTypeEnum.COMMON) - # TODO: Make it - #all_locations = - #preferred_locations = - health_multiplier = fields.FloatField(default=0.5) damage_multiplier = fields.FloatField(default=0.5) @@ -119,10 +115,13 @@ class Meta: 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") @@ -135,6 +134,7 @@ class Meta: def __str__(self) -> str: return f'' + class MarketItem(Model): id = fields.BigIntField(pk=True) diff --git a/src/bot/game/logic/calculation.py b/src/bot/game/logic/calculation.py index 541cfbd..bc010b3 100644 --- a/src/bot/game/logic/calculation.py +++ b/src/bot/game/logic/calculation.py @@ -8,16 +8,12 @@ # TODO: Сделать классы для обработки общего урона-брони для используемых предметов class BaseCalculation: - def __init__(self, user: Users) -> None: - self.user = user + def __init__(self) -> None: self.inventory_service = InventoryService() class DamageCalculation(BaseCalculation): - def __init__(self, user: Users) -> None: - super().__init__(user) - - async def calculate_damage(self) -> int: - equipped_item = await self.inventory_service.get_equipped_item(self.user, ItemTypeEnum.WEAPON) + async def calculate_damage(self, user: Users) -> int: + equipped_item = await self.inventory_service.get_equipped_item(user, ItemTypeEnum.WEAPON) if not equipped_item: return 0 @@ -35,11 +31,8 @@ async def calculate_damage(self) -> int: class ArmorCalculation(BaseCalculation): - def __init__(self, user: Users) -> None: - super().__init__(user) - - async def calculate_armor(self) -> float: - equipped_item = await self.inventory_service.get_equipped_item(self.user, ItemTypeEnum.ARMOR) + async def calculate_armor(self, user: Users) -> float: + equipped_item = await self.inventory_service.get_equipped_item(user, ItemTypeEnum.ARMOR) if not equipped_item: return 0 diff --git a/src/bot/handlers/callback/battle/singleplayer/deps.py b/src/bot/handlers/callback/battle/singleplayer/deps.py index 9749c13..c84bcee 100644 --- a/src/bot/handlers/callback/battle/singleplayer/deps.py +++ b/src/bot/handlers/callback/battle/singleplayer/deps.py @@ -4,8 +4,14 @@ from .....services.inventory import InventoryService from .....services.user import UserService +from .....game.logic.calculation import EnemyCalculator, EnemyTypeEnum, ArmorCalculation, DamageCalculation + singleplayer_router = Router() user_service = UserService() inventory_service = InventoryService() -enemy_service = EnemyService() \ No newline at end of file +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..4bfa863 --- /dev/null +++ b/src/bot/handlers/callback/battle/singleplayer/helper.py @@ -0,0 +1,35 @@ +from .deps import enemy_service, user_service, inventory_service, enemy_calculator, armor_calculator, damage_calculator +from .....db.models import Users, Items, Locations, Enemies + + +async def find_location_by_name(location_name: str) -> Locations: + location = await Locations.get_or_none(name=location_name) + if not location: + raise Exception('Location not founded') + + return location + +async def check_available_location(user: Users, location: Locations) -> bool: + if user.lvl < location.level_required: + return True + else: + return False + +async def fighting(user: Users, location: Locations): + await check_available_location(user, location) + + 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) + + return { + "enemy_hp": enemy_hp, + "enemy_dmg": enemy_damage, + "user_hp": user_hp, + "user_dmg": user_damage, + } \ 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..cdeeb3b --- /dev/null +++ b/src/bot/handlers/callback/battle/singleplayer/view.py @@ -0,0 +1,12 @@ +from aiogram import F +from aiogram.types import CallbackQuery +from .deps import user_service, singleplayer_router, inventory_service, enemy_service +from .helper import fighting, find_location_by_name + +@singleplayer_router.callback_query(F.data == '') +async def developer_manage_menu(callback: CallbackQuery, location_name='valley'): + user = await user_service.get_by_telegram_id(callback.from_user.id) + location = await find_location_by_name(location_name) + results = await fighting(user, location) + + await callback.message.edit_text(text=f'{results}') \ No newline at end of file diff --git a/src/bot/services/enemy.py b/src/bot/services/enemy.py index 87725db..7faa71a 100644 --- a/src/bot/services/enemy.py +++ b/src/bot/services/enemy.py @@ -1,5 +1,5 @@ from .inventory import InventoryService -from ..db.models import Enemies, Items, Users, EnemyTypeEnum +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 @@ -11,10 +11,17 @@ def __init__(self) -> None: self.calc = EnemyCalculator() self.inventory = InventoryService() - async def spawn(self, user: Users) -> Enemies: + async def spawn(self, user: Users, location: Locations) -> Enemies: level = max(1, user.lvl) rarity = self._get_rarity(level) - candidates = await Enemies.filter(type=rarity, is_active=True).all() + 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) From 3c12066f80825406ac593604c368c9510ce67561 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Sun, 7 Dec 2025 21:49:42 +0500 Subject: [PATCH 24/28] Refactor battle callback handlers to improve structure and clarity. Update main.py to handle KeyboardInterrupt instead of InterruptedError for better user feedback. Modify damage calculation logic to use randint for more accurate damage values. Enhance EnemyCalculator methods to be asynchronous and accept user parameters directly, improving flexibility in calculations. Update battle menu and singleplayer logic to reflect new routing and reward structures. --- main.py | 4 ++-- src/bot/bot.py | 6 ++++-- src/bot/game/logic/calculation.py | 18 ++++++++++-------- src/bot/handlers/callback/battle/default.py | 2 +- .../callback/battle/singleplayer/__init__.py | 2 +- .../callback/battle/singleplayer/helper.py | 8 ++++++++ .../callback/battle/singleplayer/view.py | 4 ++-- src/bot/keyboards/inlines/default.py | 2 +- 8 files changed, 29 insertions(+), 17 deletions(-) diff --git a/main.py b/main.py index 8d76597..83f2742 100644 --- a/main.py +++ b/main.py @@ -23,8 +23,8 @@ async def main() -> None: await init_db() try: await bootstrap() - - except InterruptedError: + + except KeyboardInterrupt: print("Telegram bot was closed!") finally: diff --git a/src/bot/bot.py b/src/bot/bot.py index abb0806..c636d50 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -12,7 +12,8 @@ async def bootstrap() -> None: from .middlewares.logging import LoggingMiddleware from src.bot.handlers.default import default_router - from src.bot.handlers.callback.battle import callback_battle_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 @@ -22,7 +23,8 @@ async def bootstrap() -> None: dp.include_routers( default_router, callback_market_router, - callback_battle_router, + callback_menu_battle, + callback_singleplayer_router, callback_inventory_router, callback_statistics_router, ) diff --git a/src/bot/game/logic/calculation.py b/src/bot/game/logic/calculation.py index bc010b3..7b11227 100644 --- a/src/bot/game/logic/calculation.py +++ b/src/bot/game/logic/calculation.py @@ -19,7 +19,7 @@ async def calculate_damage(self, user: Users) -> int: weapon_attributes = equipped_item.item.attributes - damage = gauss(weapon_attributes.get("min_damage"), weapon_attributes.get("max_damage")) + damage = randint(weapon_attributes.get("min_damage"), weapon_attributes.get("max_damage")) if random() < weapon_attributes.get("critical_chance"): damage *= weapon_attributes.get("critical_multiplier") @@ -54,21 +54,23 @@ class EnemyCalculator: 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]: bonus = self.RARITY_BONUS.get(enemy.type, 1.0) - user_damage = await DamageCalculation(user).calculate_damage() - user_armor = await ArmorCalculation(user).calculate_armor() + user_damage = await DamageCalculation().calculate_damage(user) + user_armor = await ArmorCalculation().calculate_armor(user) hp = floor( - 0 + user.lvl * enemy.health_multiplier - * (1+ user_armor) * 0.85 + * (1 + user_armor) * 0.85 * bonus * 0.85 ) damage = floor( - 0 + user.lvl * enemy.damage_multiplier * (1 + user_damage) * 0.85 * bonus * 0.85 @@ -79,7 +81,7 @@ async def get_stats(self, enemy: Enemies, user: Users) -> Dict[str, int]: "damage": max(damage, 1), } - def get_rewards(self, enemy: Enemies, player_level: int) -> Dict[str, int]: + async def get_rewards(self, enemy: Enemies, player_level: int) -> Dict[str, int]: rarity_bonus = self.RARITY_BONUS.get(enemy.type.value, 1.0) coin = floor( @@ -98,6 +100,6 @@ def get_rewards(self, enemy: Enemies, player_level: int) -> Dict[str, int]: "exp": max(exp, 1), } - def get_drop_chance(self, enemy: Enemies, player_level: int) -> float: + async def get_drop_chance(self, enemy: Enemies, player_level: int) -> float: 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/handlers/callback/battle/default.py b/src/bot/handlers/callback/battle/default.py index c0b6d80..a25ff04 100644 --- a/src/bot/handlers/callback/battle/default.py +++ b/src/bot/handlers/callback/battle/default.py @@ -5,7 +5,7 @@ callback_menu_battle = Router() @callback_menu_battle.callback_query(F.data == 'battle_menu') -async def battle_menu(callback: CallbackQuery): +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') diff --git a/src/bot/handlers/callback/battle/singleplayer/__init__.py b/src/bot/handlers/callback/battle/singleplayer/__init__.py index 80c74cc..6919813 100644 --- a/src/bot/handlers/callback/battle/singleplayer/__init__.py +++ b/src/bot/handlers/callback/battle/singleplayer/__init__.py @@ -1,4 +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/helper.py b/src/bot/handlers/callback/battle/singleplayer/helper.py index 4bfa863..6f9b6a9 100644 --- a/src/bot/handlers/callback/battle/singleplayer/helper.py +++ b/src/bot/handlers/callback/battle/singleplayer/helper.py @@ -27,9 +27,17 @@ async def fighting(user: Users, location: Locations): user_damage = await damage_calculator.calculate_damage(user) user_hp = await armor_calculator.calculate_armor(user) + + rewarding = await enemy_calculator.get_rewards(enemy, user.lvl) + drop_chance = await enemy_calculator.get_drop_chance(enemy, user.lvl) + + return { "enemy_hp": enemy_hp, "enemy_dmg": enemy_damage, "user_hp": user_hp, "user_dmg": user_damage, + "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 index cdeeb3b..37515c7 100644 --- a/src/bot/handlers/callback/battle/singleplayer/view.py +++ b/src/bot/handlers/callback/battle/singleplayer/view.py @@ -3,8 +3,8 @@ from .deps import user_service, singleplayer_router, inventory_service, enemy_service from .helper import fighting, find_location_by_name -@singleplayer_router.callback_query(F.data == '') -async def developer_manage_menu(callback: CallbackQuery, location_name='valley'): +@singleplayer_router.callback_query(F.data == 'battle_singleplayer') +async def developer_manage_menu(callback: CallbackQuery, location_name='Лес Теней'): user = await user_service.get_by_telegram_id(callback.from_user.id) location = await find_location_by_name(location_name) results = await fighting(user, location) diff --git a/src/bot/keyboards/inlines/default.py b/src/bot/keyboards/inlines/default.py index 9125a98..081bad2 100644 --- a/src/bot/keyboards/inlines/default.py +++ b/src/bot/keyboards/inlines/default.py @@ -6,7 +6,7 @@ async def mainmenu_keyboard() -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() - kb.button(text='Начать сражение', callback_data='battle') + 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') From 2140452928d2debfdebef930a3c527a00869cdef Mon Sep 17 00:00:00 2001 From: hell4uk Date: Thu, 11 Dec 2025 20:06:30 +0500 Subject: [PATCH 25/28] Enchance fighting function and created attributes for Enchants and Cases. --- requirements.txt | Bin 1270 -> 0 bytes src/bot/db/models.py | 2 ++ src/bot/db/schemas/items.py | 10 +++++++++- .../callback/battle/singleplayer/helper.py | 15 +++++++++++++++ .../callback/battle/singleplayer/view.py | 15 ++++++++++++++- 5 files changed, 40 insertions(+), 2 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 1b103c934bf63bdcfa5f6392bb86d9ad0ba28d94..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1270 zcmZvbO-{p541~Q#;wV8hr3Dr&xB(IeKq38$Xp@i>L^(V#pWW94gr3r6t6t@3)$$n8q;iJr?*&RQsi?L1@Z_4%KSjpD7& zK}bB$`oGmls24VO+@bHr_B!neb0SuBo{jqU;H;s_NqRWu$GR6#{8Fp zS_qBeh370R)BB*#Ni!AKBh*?^oS|d5G5B$B5BBJOE^T3N?p)`Y_%iv`w--*W+|v1# z_TqUr$|EXL7EbK#Lh;&N zEJv*9h=(bwRj(IivO_q@FNy|Kx(7Q@_v+XPHxoWPh-dGPAIP95?7cjN+c3Sr=~RWj zxJ#idxz!6VW|cM;-0&ORzbrFn9XB~{N^D8waw{q!uEm#k@3(}F0KIm0RN-83i<=ZR uPvZG;)X2@HA~=WBbP6X?ir?JH{}dBvQ<>S;GpHR@9rV3BM$nt bool: else: return False +async def reduce_damage(raw_damage: float, target_armor: float, k: float = 80.0) -> float: + return raw_damage * (1 - target_armor / (target_armor + k)) + async def fighting(user: Users, location: Locations): await check_available_location(user, location) @@ -27,16 +30,28 @@ async def fighting(user: Users, location: Locations): user_damage = await damage_calculator.calculate_damage(user) user_hp = await armor_calculator.calculate_armor(user) + user_effective_damage = await reduce_damage(user_damage, user_hp) + enemy_effective_damage = await 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 'emeny', "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, diff --git a/src/bot/handlers/callback/battle/singleplayer/view.py b/src/bot/handlers/callback/battle/singleplayer/view.py index 37515c7..dde9230 100644 --- a/src/bot/handlers/callback/battle/singleplayer/view.py +++ b/src/bot/handlers/callback/battle/singleplayer/view.py @@ -9,4 +9,17 @@ async def developer_manage_menu(callback: CallbackQuery, location_name='Лес location = await find_location_by_name(location_name) results = await fighting(user, location) - await callback.message.edit_text(text=f'{results}') \ No newline at end of file + await callback.message.edit_text(text=f'{results}') + +@singleplayer_router.callback_query(F.data == 'battle_singleplayer') +async def confirm_menu_battle(callback: CallbackQuery): + pass + +@singleplayer_router.callback_query(F.data == "") +async def start_fighting(callback: CallbackQuery): + pass + +@singleplayer_router.callback_query(F.data == '') +async def func1(callback: CallbackQuery): + pass + From 288f9ba7b5c8dc5be4f5e6492a17b6a360d44506 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Sat, 13 Dec 2025 21:26:34 +0500 Subject: [PATCH 26/28] Created base case system enchance fight system, itemservice --- requirements.txt | 34 +++++++++++++++++++ src/bot/db/schemas/items.py | 9 ++--- .../callback/battle/singleplayer/deps.py | 1 + .../callback/battle/singleplayer/helper.py | 7 ++-- .../callback/battle/singleplayer/view.py | 33 ++++++++++-------- src/bot/keyboards/inlines/battle.py | 8 ++++- src/bot/services/enemy.py | 9 +++-- src/bot/services/item.py | 6 +++- 8 files changed, 76 insertions(+), 31 deletions(-) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8089b66 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,34 @@ +aerich==0.9.2 +aiofiles==24.1.0 +aiogram==3.22.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.12.15 +aiosignal==1.4.0 +aiosqlite==0.21.0 +annotated-types==0.7.0 +anyio==4.11.0 +APScheduler==3.11.0 +asyncclick==8.3.0.7 +asyncpg==0.30.0 +attrs==25.4.0 +certifi==2025.10.5 +colorama==0.4.6 +dictdiffer==0.9.0 +frozenlist==1.8.0 +idna==3.11 +iso8601==2.1.0 +magic-filter==1.0.12 +multidict==6.7.0 +propcache==0.4.1 +pydantic==2.11.10 +pypika-tortoise==0.6.2 +python-dotenv==1.1.1 +pytz==2025.2 +redis==7.0.0 +sniffio==1.3.1 +tortoise-orm==0.25.1 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2025.2 +tzlocal==5.3.1 +yarl==1.22.0 \ No newline at end of file diff --git a/src/bot/db/schemas/items.py b/src/bot/db/schemas/items.py index dd20adc..9a2d69e 100644 --- a/src/bot/db/schemas/items.py +++ b/src/bot/db/schemas/items.py @@ -1,11 +1,11 @@ from logging import critical from pydantic import BaseModel, Field -from typing import Optional -from ..models import ItemTypeEnum +from typing import Optional, List, Coroutine +from ..models import ItemTypeEnum, Items class BaseAttributes(BaseModel): - item_level: int = Field(..., ge=1) + pass class WeaponAttributes(BaseAttributes): min_damage: int = Field(..., ge=1) @@ -27,4 +27,5 @@ class EnchantAttributes(BaseAttributes): class CaseAttributes(BaseModel): collection: str = Field(...) - storage: str = Field(...) \ No newline at end of file + storage: List[Items] = Field(...) + diff --git a/src/bot/handlers/callback/battle/singleplayer/deps.py b/src/bot/handlers/callback/battle/singleplayer/deps.py index c84bcee..0fac45c 100644 --- a/src/bot/handlers/callback/battle/singleplayer/deps.py +++ b/src/bot/handlers/callback/battle/singleplayer/deps.py @@ -5,6 +5,7 @@ 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_router = Router() diff --git a/src/bot/handlers/callback/battle/singleplayer/helper.py b/src/bot/handlers/callback/battle/singleplayer/helper.py index 0c84d89..13b900d 100644 --- a/src/bot/handlers/callback/battle/singleplayer/helper.py +++ b/src/bot/handlers/callback/battle/singleplayer/helper.py @@ -10,10 +10,7 @@ async def find_location_by_name(location_name: str) -> Locations: return location async def check_available_location(user: Users, location: Locations) -> bool: - if user.lvl < location.level_required: - return True - else: - return False + return True if location.level_required < user.lvl else False async def reduce_damage(raw_damage: float, target_armor: float, k: float = 80.0) -> float: return raw_damage * (1 - target_armor / (target_armor + k)) @@ -43,7 +40,7 @@ async def fighting(user: Users, location: Locations): return { - 'winner': 'user' if user_wins else 'emeny', + 'winner': 'user' if user_wins else 'enemy', "enemy_hp": enemy_hp, "enemy_dmg": enemy_damage, "user_hp": user_hp, diff --git a/src/bot/handlers/callback/battle/singleplayer/view.py b/src/bot/handlers/callback/battle/singleplayer/view.py index dde9230..7ca845f 100644 --- a/src/bot/handlers/callback/battle/singleplayer/view.py +++ b/src/bot/handlers/callback/battle/singleplayer/view.py @@ -1,25 +1,28 @@ from aiogram import F from aiogram.types import CallbackQuery -from .deps import user_service, singleplayer_router, inventory_service, enemy_service +from .deps import user_service, singleplayer_router, inventory_service, enemy_service, finished_singleplayer_fight, battle_menu from .helper import fighting, find_location_by_name -@singleplayer_router.callback_query(F.data == 'battle_singleplayer') -async def developer_manage_menu(callback: CallbackQuery, location_name='Лес Теней'): + +@singleplayer_router.callback_query(F.data == 'battle_singleplayer:start') +async def start_singleplayer_battle(callback: CallbackQuery, location_name: str = 'Лес Теней'): user = await user_service.get_by_telegram_id(callback.from_user.id) location = await find_location_by_name(location_name) - results = await fighting(user, location) - - await callback.message.edit_text(text=f'{results}') + result = await fighting(user, location) + + result_fight = 'вы выйграли' if result['winner'] == 'user' else 'вы програли' -@singleplayer_router.callback_query(F.data == 'battle_singleplayer') -async def confirm_menu_battle(callback: CallbackQuery): - pass + BASE_TEXT = f""" +{callback.from_user.first_name}, {result_fight}. Вам попался: {result['enemy'].name} -@singleplayer_router.callback_query(F.data == "") -async def start_fighting(callback: CallbackQuery): - pass +Ваш урон: {result['user_dmg']} +Ваша броня: {result['user_hp']} -@singleplayer_router.callback_query(F.data == '') -async def func1(callback: CallbackQuery): - pass +Урон противника: {result['enemy_dmg']} +Броня противника: {result['enemy_hp']} +За игру вы получили: {result['reward']['coins']} урона, {result['reward']['exp']} опыта +""" + await enemy_service.give_reward(user, result['enemy'], result['reward']['exp'], result['reward']['coins']) + 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/keyboards/inlines/battle.py b/src/bot/keyboards/inlines/battle.py index 5951b2e..a3142cb 100644 --- a/src/bot/keyboards/inlines/battle.py +++ b/src/bot/keyboards/inlines/battle.py @@ -30,4 +30,10 @@ async def confirm_singleplayer_battle() -> InlineKeyboardMarkup: kb.button(text='Да', callback_data='battle_singleplayer:confirm') kb.button(text='Нет', callback_data="battle_singleplayer:cancel") - return kb.adjust(1).as_markup() \ No newline at end of file + return kb.adjust(1).as_markup() + +async def finished_singleplayer_fight() -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + + kb.button(text='', callback_data='battle_singleplayer:start') + kb.button(text='', callbac_data='battle_singleplayer:back') \ No newline at end of file diff --git a/src/bot/services/enemy.py b/src/bot/services/enemy.py index 7faa71a..89bdd62 100644 --- a/src/bot/services/enemy.py +++ b/src/bot/services/enemy.py @@ -29,17 +29,16 @@ async def spawn(self, user: Users, location: Locations) -> Enemies: 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) -> Dict: - stats = self.calc.get_rewards(enemy, user.lvl) - user.coins += stats['coin'] - user.exp += stats['exp'] + async def give_reward(self, user: Users, enemy: Enemies, exp: int, coins: int) -> Dict: + 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": stats['coin'], "exp": stats['exp'], + "coin": coins, "exp": exp, 'level_up': leveled_up, 'drop': [f"{d['item'].name} x{d['quantity']}" for d in drops] } diff --git a/src/bot/services/item.py b/src/bot/services/item.py index 6bf1bf2..84e42a8 100644 --- a/src/bot/services/item.py +++ b/src/bot/services/item.py @@ -47,7 +47,11 @@ async def get_by_id(self, item_id: int) -> Optional[Items]: 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) + return items + async def update(self, item_id: str, **updates: any) -> Items: item = await self.get_by_id(item_id) From 6e887192059d47467688d438d1c19a7bfa492652 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Sun, 14 Dec 2025 17:47:36 +0500 Subject: [PATCH 27/28] enchanced items validator, battle system, enemy system --- src/bot/db/schemas/items.py | 2 +- .../callback/battle/singleplayer/deps.py | 2 +- .../callback/battle/singleplayer/helper.py | 7 ++++++ .../callback/battle/singleplayer/view.py | 23 +++++++++++++------ src/bot/keyboards/inlines/battle.py | 6 ++--- src/bot/services/enemy.py | 2 +- 6 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/bot/db/schemas/items.py b/src/bot/db/schemas/items.py index 9a2d69e..fb62e9f 100644 --- a/src/bot/db/schemas/items.py +++ b/src/bot/db/schemas/items.py @@ -27,5 +27,5 @@ class EnchantAttributes(BaseAttributes): class CaseAttributes(BaseModel): collection: str = Field(...) - storage: List[Items] = Field(...) + storage: List[int] = Field(...) diff --git a/src/bot/handlers/callback/battle/singleplayer/deps.py b/src/bot/handlers/callback/battle/singleplayer/deps.py index 0fac45c..c7be1e2 100644 --- a/src/bot/handlers/callback/battle/singleplayer/deps.py +++ b/src/bot/handlers/callback/battle/singleplayer/deps.py @@ -5,7 +5,7 @@ from .....services.user import UserService from .....game.logic.calculation import EnemyCalculator, EnemyTypeEnum, ArmorCalculation, DamageCalculation -from .....keyboards.inlines.battle import battle_menu, finished_singleplayer_fight +from .....keyboards.inlines.battle import battle_menu, finished_singleplayer_fight, singleplayer_menu_location singleplayer_router = Router() diff --git a/src/bot/handlers/callback/battle/singleplayer/helper.py b/src/bot/handlers/callback/battle/singleplayer/helper.py index 13b900d..6614343 100644 --- a/src/bot/handlers/callback/battle/singleplayer/helper.py +++ b/src/bot/handlers/callback/battle/singleplayer/helper.py @@ -9,6 +9,13 @@ async def find_location_by_name(location_name: str) -> Locations: return location +async def find_location_by_id(location_id: int) -> Locations: + location = await Locations.get_or_none(id=location_id) + if not location: + raise Exception('Location not founded') + + return location + async def check_available_location(user: Users, location: Locations) -> bool: return True if location.level_required < user.lvl else False diff --git a/src/bot/handlers/callback/battle/singleplayer/view.py b/src/bot/handlers/callback/battle/singleplayer/view.py index 7ca845f..b1e56b4 100644 --- a/src/bot/handlers/callback/battle/singleplayer/view.py +++ b/src/bot/handlers/callback/battle/singleplayer/view.py @@ -1,13 +1,22 @@ 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 -from .helper import fighting, find_location_by_name +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 -@singleplayer_router.callback_query(F.data == 'battle_singleplayer:start') -async def start_singleplayer_battle(callback: CallbackQuery, location_name: str = 'Лес Теней'): user = await user_service.get_by_telegram_id(callback.from_user.id) - location = await find_location_by_name(location_name) + location = await find_location_by_id(match.group(1)) result = await fighting(user, location) result_fight = 'вы выйграли' if result['winner'] == 'user' else 'вы програли' @@ -21,8 +30,8 @@ async def start_singleplayer_battle(callback: CallbackQuery, location_name: str Урон противника: {result['enemy_dmg']} Броня противника: {result['enemy_hp']} -За игру вы получили: {result['reward']['coins']} урона, {result['reward']['exp']} опыта +За игру вы получили: {result['reward']['coin']} урона, {result['reward']['exp']} опыта """ - await enemy_service.give_reward(user, result['enemy'], result['reward']['exp'], result['reward']['coins']) + 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/keyboards/inlines/battle.py b/src/bot/keyboards/inlines/battle.py index a3142cb..4117c4a 100644 --- a/src/bot/keyboards/inlines/battle.py +++ b/src/bot/keyboards/inlines/battle.py @@ -18,7 +18,7 @@ async def singleplayer_menu_location() -> InlineKeyboardMarkup: locations = await Locations.all() for loc in locations: - kb.button(text=loc.name, callback_data=f"battle_loc_{loc.id}") + kb.button(text=loc.name, callback_data=f"battle_singleplayer:start:{loc.id}") kb.button(text="Назад", callback_data="battle_menu") @@ -35,5 +35,5 @@ async def confirm_singleplayer_battle() -> InlineKeyboardMarkup: async def finished_singleplayer_fight() -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() - kb.button(text='', callback_data='battle_singleplayer:start') - kb.button(text='', callbac_data='battle_singleplayer:back') \ No newline at end of file + kb.button(text='Заного', callback_data='battle_singleplayer:start') + kb.button(text='Назад', callbac_data='battle_menu') \ No newline at end of file diff --git a/src/bot/services/enemy.py b/src/bot/services/enemy.py index 89bdd62..85cdae4 100644 --- a/src/bot/services/enemy.py +++ b/src/bot/services/enemy.py @@ -50,7 +50,7 @@ def _get_rarity(self, level: int) -> EnemyTypeEnum: return EnemyTypeEnum.COMMON async def _roll_drops(self, enemy: Enemies) -> List[Dict]: - chance = self.calc.get_drop_chance(enemy, 1) # player_level TODO: from user + chance = await self.calc.get_drop_chance(enemy, 1) # player_level TODO: from user if random() * 100 > chance: return [] drops = [] From 9f5a02b08b0f786d4d3d4ac1e0a478a64d713136 Mon Sep 17 00:00:00 2001 From: hell4uk Date: Mon, 12 Jan 2026 10:22:42 +0500 Subject: [PATCH 28/28] feat: Implement case management system with opening and purchasing functionality - Added case initialization script to populate the database with various cases. - Created CaseService for managing cases, including opening cases and retrieving case information. - Developed handlers for case-related callbacks, including opening and buying cases. - Enhanced inventory and market services to support case operations. - Updated inline keyboards for case interactions in the bot. - Improved user experience with detailed messages and error handling for case operations. --- scripts/init_cases.py | 170 ++++++++++++++++ src/bot/bot.py | 2 + src/bot/db/models.py | 3 +- src/bot/db/schemas/items.py | 11 +- src/bot/game/logic/calculation.py | 178 +++++++++++++++-- .../callback/battle/singleplayer/helper.py | 111 ++++++++++- .../callback/battle/singleplayer/view.py | 6 +- src/bot/handlers/callback/case/__init__.py | 4 + src/bot/handlers/callback/case/handler.py | 171 ++++++++++++++++ .../handlers/callback/inventory/helpers.py | 39 +++- src/bot/handlers/default.py | 23 ++- src/bot/keyboards/inlines/battle.py | 6 +- src/bot/keyboards/inlines/case.py | 81 ++++++++ src/bot/keyboards/inlines/inventory.py | 11 +- src/bot/keyboards/inlines/market.py | 3 +- src/bot/services/case.py | 184 ++++++++++++++++++ src/bot/services/enemy.py | 97 +++++++++ src/bot/services/inventory.py | 85 ++++++++ src/bot/services/item.py | 81 ++++++-- src/bot/services/market.py | 4 +- 20 files changed, 1199 insertions(+), 71 deletions(-) create mode 100644 scripts/init_cases.py create mode 100644 src/bot/handlers/callback/case/__init__.py create mode 100644 src/bot/handlers/callback/case/handler.py create mode 100644 src/bot/keyboards/inlines/case.py create mode 100644 src/bot/services/case.py 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/src/bot/bot.py b/src/bot/bot.py index c636d50..72bf734 100644 --- a/src/bot/bot.py +++ b/src/bot/bot.py @@ -17,6 +17,7 @@ async def bootstrap() -> None: 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()) @@ -27,6 +28,7 @@ async def bootstrap() -> None: callback_singleplayer_router, callback_inventory_router, callback_statistics_router, + case_router, ) dp.message.outer_middleware(SafePatchMiddleware()) diff --git a/src/bot/db/models.py b/src/bot/db/models.py index 701c11f..1a672b5 100644 --- a/src/bot/db/models.py +++ b/src/bot/db/models.py @@ -36,7 +36,7 @@ class Users(Model): sp_loses = fields.IntField(default=0) mp_wins = fields.IntField(default=0) - mp_loses = fields.IntField(default=0) + mp_losses = fields.IntField(default=0) elo = fields.IntField(default=0) @@ -47,6 +47,7 @@ class Meta: table = 'users' ordering = ['id', 'username'] + @property def __str__(self) -> str: return f"" diff --git a/src/bot/db/schemas/items.py b/src/bot/db/schemas/items.py index fb62e9f..158533b 100644 --- a/src/bot/db/schemas/items.py +++ b/src/bot/db/schemas/items.py @@ -1,7 +1,7 @@ from logging import critical from pydantic import BaseModel, Field -from typing import Optional, List, Coroutine -from ..models import ItemTypeEnum, Items +from typing import Optional, List, Coroutine, Dict +from ..models import ItemTypeEnum, Items, ItemRarityEnum class BaseAttributes(BaseModel): @@ -16,7 +16,6 @@ class WeaponAttributes(BaseAttributes): 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) @@ -27,5 +26,7 @@ class EnchantAttributes(BaseAttributes): class CaseAttributes(BaseModel): collection: str = Field(...) - storage: List[int] = 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/logic/calculation.py b/src/bot/game/logic/calculation.py index 7b11227..8eaf23a 100644 --- a/src/bot/game/logic/calculation.py +++ b/src/bot/game/logic/calculation.py @@ -1,3 +1,8 @@ +"""Модуль расчётов боевых характеристик: урон, броня, награды. + +Содержит калькуляторы для вычисления характеристик игрока и врага на основе +оборудования, уровня и множителей редкости. +""" from typing import Dict from ...db.models import InventoryItems, ItemRarityEnum, ItemTypeEnum, Users, Enemies, EnemyTypeEnum from ...services.inventory import InventoryService @@ -8,47 +13,117 @@ # 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: + if not equipped_item or not equipped_item.item: return 0 - weapon_attributes = equipped_item.item.attributes + weapon_attributes = equipped_item.item.attributes if equipped_item.item.attributes else {} - damage = randint(weapon_attributes.get("min_damage"), weapon_attributes.get("max_damage")) - if random() < weapon_attributes.get("critical_chance"): - damage *= weapon_attributes.get("critical_multiplier") + min_dmg = weapon_attributes.get("min_damage", 1) + max_dmg = weapon_attributes.get("max_damage", 1) + damage = randint(int(min_dmg), int(max_dmg)) - damage *= weapon_attributes.get("attack_speed") - damage *= 1 + (weapon_attributes.get("item_level") * 0.02) - damage = floor(damage) - - return damage + 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: - return 0 - - armor_attributes = equipped_item.item.attributes + if not equipped_item or not equipped_item.item: + return 0.0 - armor = armor_attributes.get("defense") - armor *= 1 + (armor_attributes.get("health_bonus") * random()) - armor *= 1 + (armor_attributes.get("item_level") * 0.02) - armor = floor(armor) + armor_attributes = equipped_item.item.attributes if equipped_item.item.attributes else {} - return armor + 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, @@ -58,6 +133,23 @@ class EnemyCalculator: 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) @@ -82,7 +174,28 @@ async def get_stats(self, enemy: Enemies, user: Users) -> Dict[str, int]: } async def get_rewards(self, enemy: Enemies, player_level: int) -> Dict[str, int]: - rarity_bonus = self.RARITY_BONUS.get(enemy.type.value, 1.0) + """Рассчитывает награды за победу над врагом. + + Награда зависит от множителей врага и его редкости. + + Формула: + 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 @@ -101,5 +214,28 @@ async def get_rewards(self, enemy: Enemies, player_level: int) -> Dict[str, int] } 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 + return min(100.0, enemy.drop_chance + bonus) + \ 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 index 6614343..3451d58 100644 --- a/src/bot/handlers/callback/battle/singleplayer/helper.py +++ b/src/bot/handlers/callback/battle/singleplayer/helper.py @@ -1,29 +1,120 @@ +"""Вспомогательные функции для системы боя 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 Exception('Location not founded') - + 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 Exception('Location not founded') + raise LocationNotFound(f'Location with id {location_id} not found') return location async def check_available_location(user: Users, location: Locations) -> bool: - return True if location.level_required < user.lvl else False - -async def reduce_damage(raw_damage: float, target_armor: float, k: float = 80.0) -> float: - return raw_damage * (1 - target_armor / (target_armor + k)) + """Проверяет, может ли игрок посетить локацию. + + 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): - await check_available_location(user, location) + """Симулирует бой между игроком и врагом. + + Полный процесс боя: + 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) @@ -34,8 +125,8 @@ async def fighting(user: Users, location: Locations): user_damage = await damage_calculator.calculate_damage(user) user_hp = await armor_calculator.calculate_armor(user) - user_effective_damage = await reduce_damage(user_damage, user_hp) - enemy_effective_damage = await reduce_damage(enemy_damage, enemy_hp) + 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) diff --git a/src/bot/handlers/callback/battle/singleplayer/view.py b/src/bot/handlers/callback/battle/singleplayer/view.py index b1e56b4..0f5c914 100644 --- a/src/bot/handlers/callback/battle/singleplayer/view.py +++ b/src/bot/handlers/callback/battle/singleplayer/view.py @@ -16,10 +16,10 @@ async def start_singleplayer_battle(callback: CallbackQuery): return user = await user_service.get_by_telegram_id(callback.from_user.id) - location = await find_location_by_id(match.group(1)) + location = await find_location_by_id(int(match.group(1))) result = await fighting(user, location) - result_fight = 'вы выйграли' if result['winner'] == 'user' else 'вы програли' + result_fight = 'вы выиграли' if result['winner'] == 'user' else 'вы проиграли' BASE_TEXT = f""" {callback.from_user.first_name}, {result_fight}. Вам попался: {result['enemy'].name} @@ -30,7 +30,7 @@ async def start_singleplayer_battle(callback: CallbackQuery): Урон противника: {result['enemy_dmg']} Броня противника: {result['enemy_hp']} -За игру вы получили: {result['reward']['coin']} урона, {result['reward']['exp']} опыта +За игру вы получили: {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()) 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/helpers.py b/src/bot/handlers/callback/inventory/helpers.py index 385ee47..1150c16 100644 --- a/src/bot/handlers/callback/inventory/helpers.py +++ b/src/bot/handlers/callback/inventory/helpers.py @@ -8,11 +8,25 @@ def item_type_slug(item_type: ItemTypeEnum) -> str: - return "weapon" if item_type == ItemTypeEnum.WEAPON else "armor" + 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: - return "оружия" if item_type == ItemTypeEnum.WEAPON else "брони" + 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]: @@ -35,7 +49,23 @@ def format_item_detail_text(inv_item) -> str: "", ] - if "min_damage" in attributes and "max_damage" in attributes: + # Обработка кейсов + 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") @@ -49,6 +79,7 @@ def format_item_detail_text(inv_item) -> str: 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) @@ -63,7 +94,7 @@ def format_item_detail_text(inv_item) -> str: parts += [ "", - "Продать — выставить предмет на рынок и получить монеты.", + "Продать — выставить предмет на рынок и получить монеты." if item.type != ItemTypeEnum.CASE else "Открыть — получить случайный предмет из коллекции.", ] return "\n".join(parts) diff --git a/src/bot/handlers/default.py b/src/bot/handlers/default.py index 786ba8f..0fbc961 100644 --- a/src/bot/handlers/default.py +++ b/src/bot/handlers/default.py @@ -6,14 +6,29 @@ 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 UserService().create(telegram_id=message.from_user.id, username=message.from_user.username, first_name=message.from_user.first_name, last_name=message.from_user.last_name) - await message.reply(text=await TelegramTextMap.GREETING_TEXT(message), reply_markup=await mainmenu_keyboard()) + 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 UserService().get_by_telegram_id(callback.from_user.id) - await callback.message.edit_text(text=await TelegramTextMap.MAINMENU_TEXT(callback), reply_markup=await mainmenu_keyboard()) + 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 index 4117c4a..52c6d79 100644 --- a/src/bot/keyboards/inlines/battle.py +++ b/src/bot/keyboards/inlines/battle.py @@ -35,5 +35,7 @@ async def confirm_singleplayer_battle() -> InlineKeyboardMarkup: async def finished_singleplayer_fight() -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() - kb.button(text='Заного', callback_data='battle_singleplayer:start') - kb.button(text='Назад', callbac_data='battle_menu') \ No newline at end of file + 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/inventory.py b/src/bot/keyboards/inlines/inventory.py index e011eac..1bfc738 100644 --- a/src/bot/keyboards/inlines/inventory.py +++ b/src/bot/keyboards/inlines/inventory.py @@ -13,8 +13,9 @@ 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) + 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: @@ -50,7 +51,13 @@ def inventory_items_keyboard(items: list, page: int = 0, total_pages: int = 1, i def inventory_item_view_keyboard(item_id: int, item_type: str) -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() - kb.button(text="💰 Продать", callback_data=f"inv_sell_{item_id}") + + # Для кейсов показываем кнопку открытия вместо продажи + 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() diff --git a/src/bot/keyboards/inlines/market.py b/src/bot/keyboards/inlines/market.py index 95991c4..01c3dc0 100644 --- a/src/bot/keyboards/inlines/market.py +++ b/src/bot/keyboards/inlines/market.py @@ -15,6 +15,7 @@ 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") @@ -22,7 +23,7 @@ def market_main_keyboard(rarity_label: str) -> InlineKeyboardMarkup: 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, 2, 2, 2, 1) + kb.adjust(2, 1, 2, 2, 2, 1) return kb.as_markup() 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 index 85cdae4..28be5cc 100644 --- a/src/bot/services/enemy.py +++ b/src/bot/services/enemy.py @@ -1,3 +1,8 @@ +"""Сервис управления врагами и боевой логикой. + +Обрабатывает спавн врагов по уровню, расчёт наград и выпадение лута. +Интегрирует калькулятор боевых характеристик для расчёта HP и урона врага. +""" from .inventory import InventoryService from ..db.models import Enemies, Items, Users, EnemyTypeEnum, Locations from typing import List, Dict @@ -7,11 +12,37 @@ # 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( @@ -30,6 +61,27 @@ 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) @@ -43,6 +95,26 @@ async def give_reward(self, user: Users, enemy: Enemies, exp: int, coins: int) - } 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: @@ -50,6 +122,20 @@ def _get_rarity(self, level: int) -> EnemyTypeEnum: 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 [] @@ -61,6 +147,17 @@ async def _roll_drops(self, enemy: Enemies) -> List[Dict]: 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: diff --git a/src/bot/services/inventory.py b/src/bot/services/inventory.py index 1d6420f..be82d99 100644 --- a/src/bot/services/inventory.py +++ b/src/bot/services/inventory.py @@ -1,3 +1,8 @@ +"""Сервис управления инвентарём игрока. + +Обрабатывает добавление, удаление, экипирование/разоружение предметов. +Гарантирует, что одновременно экипирован только один предмет каждого типа. +""" from typing import Optional, List from tortoise.exceptions import DoesNotExist from ...bot.db.models import InventoryItems, Users, Items, ItemTypeEnum @@ -6,11 +11,36 @@ # 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 @@ -25,6 +55,18 @@ async def add(self, user: Users, item: Items, quantity: int = 1, auto_create: bo 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") @@ -52,6 +94,25 @@ 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") @@ -73,6 +134,21 @@ async def equip_item(self, user: Users, item: Items) -> InventoryItems: 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") @@ -84,6 +160,15 @@ async def unequip_item(self, user: Users, item: Items) -> None: 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, diff --git a/src/bot/services/item.py b/src/bot/services/item.py index 84e42a8..5ad7001 100644 --- a/src/bot/services/item.py +++ b/src/bot/services/item.py @@ -1,4 +1,9 @@ -from typing import Optional, Type, List, Dict +"""Сервис управления предметами в системе. + +Обрабатывает создание, валидацию и 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 @@ -6,20 +11,30 @@ SCHEMAS_MAP = { ItemTypeEnum.WEAPON: WeaponAttributes, - ItemTypeEnum.ARMOR: ArmorAttributes + ItemTypeEnum.ARMOR: ArmorAttributes, } -class ItemService(): - def __init__(self, schemas_map: Optional[Dict[str, Type[BaseAttributes]]]): - self.schemas_map = schemas_map or SCHEMAS_MAP + +class ItemService: + """Сервис управления предметами. - def _get_schema_class(self, item_type: str) -> Type[BaseAttributes]: + Валидирует атрибуты через 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: str, attributes: dict) -> dict: + 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) @@ -27,12 +42,28 @@ def _validate_attributes(self, item_type: str, attributes: dict) -> 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: - valid_attrs = self._validate_attributes(item_type, attributes) + """Создаёт новый предмет или возвращает существующий. - item = await Items.get_or_create(name=name, defaults={ + Валидирует атрибуты через соответствующую 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, @@ -47,13 +78,29 @@ async def get_by_id(self, item_id: int) -> Optional[Items]: 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) + items = await Items.filter(type=item_type).all() return items - - async def update(self, item_id: str, **updates: any) -> 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"]) @@ -66,7 +113,9 @@ async def update(self, item_id: str, **updates: any) -> Items: await item.save() return item - - async def delete(self, item_id): + + 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 index 758779f..061039b 100644 --- a/src/bot/services/market.py +++ b/src/bot/services/market.py @@ -36,9 +36,9 @@ async def get_items( search: Optional[str] = None ) -> List[Items]: qs = Items.filter(type=item_type) - if rarity: + if rarity and rarity != ItemRarityEnum.COMMON: # Avoid filtering if 'all' selected qs = qs.filter(rarity=rarity) - if search: + if search and search.strip(): qs = qs.filter(name__icontains=search) return await qs.order_by("rarity", "name").all()