diff --git a/bot/action_handlers.py b/bot/action_handlers.py new file mode 100644 index 0000000..da5597a --- /dev/null +++ b/bot/action_handlers.py @@ -0,0 +1,371 @@ +""" +Action handlers for button callbacks. +This module contains organized handler functions for different types of player actions. +""" +import logging +import json +import random +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ContextTypes +from . import database, keyboards, logic +from data.world_loader import game_world +from data.items import ITEMS + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# UTILITY FUNCTIONS +# ============================================================================ + +async def get_player_status_text(telegram_id: int) -> str: + """Generate player status text with location and stats.""" + from .utils import format_stat_bar + + player = await database.get_player(telegram_id) + if not player: + return "Could not find player data." + + location = game_world.get_location(player["location_id"]) + if not location: + return "Error: Player is in an unknown location." + + inventory = await database.get_inventory(telegram_id) + weight, volume = logic.calculate_inventory_load(inventory) + max_weight, max_volume = logic.get_player_capacity(inventory, player) + + # Get equipped items + equipped_items = [] + for item in inventory: + if item.get('is_equipped'): + item_def = ITEMS.get(item['item_id'], {}) + emoji = item_def.get('emoji', 'โ”') + equipped_items.append(f"{emoji} {item_def.get('name', 'Unknown')}") + + # Build status with visual bars + status = f"๐Ÿ“ Location: {location.name}\n" + status += f"{format_stat_bar('HP', 'โค๏ธ', player['hp'], player['max_hp'])}\n" + status += f"{format_stat_bar('Stamina', 'โšก', player['stamina'], player['max_stamina'])}\n" + status += f"๐ŸŽ’ Load: {weight}/{max_weight} kg | {volume}/{max_volume} vol\n" + + if equipped_items: + status += f"โš”๏ธ Equipped: {', '.join(equipped_items)}\n" + + status += f"โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n{location.description}" + return status + + +# ============================================================================ +# INSPECTION & WORLD INTERACTION HANDLERS +# ============================================================================ + +async def handle_inspect_area(query, user_id: int, player: dict): + """Handle the inspect area action.""" + await query.answer() + location_id = player['location_id'] + location = game_world.get_location(location_id) + dropped_items = await database.get_dropped_items_in_location(location_id) + wandering_enemies = await database.get_wandering_enemies_in_location(location_id) + + keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) + image_path = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text="You scan the area. You notice...", + reply_markup=keyboard, + image_path=image_path + ) + + +async def handle_attack_wandering(query, user_id: int, player: dict, data: list): + """Handle attacking a wandering enemy.""" + enemy_db_id = int(data[1]) + await query.answer() + + # Get the enemy from database + wandering_enemies = await database.get_wandering_enemies_in_location(player['location_id']) + enemy_data = next((e for e in wandering_enemies if e['id'] == enemy_db_id), None) + + if not enemy_data: + await query.answer("That enemy has already moved on!", show_alert=True) + # Refresh inspect menu + location_id = player['location_id'] + location = game_world.get_location(location_id) + dropped_items = await database.get_dropped_items_in_location(location_id) + wandering_enemies = await database.get_wandering_enemies_in_location(location_id) + keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) + image_path = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text="You scan the area. You notice...", + reply_markup=keyboard, + image_path=image_path + ) + return + + npc_id = enemy_data['npc_id'] + + # Remove enemy from wandering table (they're now in combat) + await database.remove_wandering_enemy(enemy_db_id) + + from data.npcs import NPCS + from bot import combat + + # Initiate combat + combat_data = await combat.initiate_combat( + user_id, npc_id, player['location_id'], from_wandering_enemy=True + ) + + if combat_data: + npc_def = NPCS.get(npc_id) + message = f"โš”๏ธ You engage the {npc_def.emoji} {npc_def.name}!\n\n" + message += f"{npc_def.description}\n\n" + message += f"{npc_def.emoji} Enemy HP: {combat_data['npc_hp']}/{combat_data['npc_max_hp']}\n" + message += f"โค๏ธ Your HP: {player['hp']}/{player['max_hp']}\n\n" + message += "๐ŸŽฏ Your turn! What will you do?" + + keyboard = await keyboards.combat_keyboard(user_id) + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=message, + reply_markup=keyboard, + image_path=npc_def.image_url if npc_def else None + ) + else: + await query.answer("Failed to initiate combat.", show_alert=True) + + +async def handle_inspect_interactable(query, user_id: int, player: dict, data: list): + """Handle inspecting an interactable object.""" + location_id, instance_id = data[1], data[2] + + location = game_world.get_location(location_id) + if not location: + await query.answer("Location not found.", show_alert=True) + return + + interactable = location.get_interactable(instance_id) + if not interactable: + await query.answer("Object not found.", show_alert=False) + return + + # Check if ALL actions are on cooldown + all_on_cooldown = True + for action_id in interactable.actions.keys(): + cooldown_key = f"{instance_id}:{action_id}" + if await database.get_cooldown(cooldown_key) == 0: + all_on_cooldown = False + break + + if all_on_cooldown and len(interactable.actions) > 0: + await query.answer( + f"The {interactable.name} has already been searched. Try again later.", + show_alert=False + ) + return + + # Show action menu + await query.answer() + image_path = interactable.image_path if interactable else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=f"You focus on the {interactable.name}. What do you do?", + reply_markup=await keyboards.actions_keyboard(location_id, instance_id), + image_path=image_path + ) + + +async def handle_action(query, user_id: int, player: dict, data: list): + """Handle performing an action on an interactable object.""" + location_id, instance_id, action_id = data[1], data[2], data[3] + cooldown_key = f"{instance_id}:{action_id}" + cooldown = await database.get_cooldown(cooldown_key) + + if cooldown > 0: + await query.answer("Someone got to it just before you!", show_alert=False) + return + + location = game_world.get_location(location_id) + if not location: + await query.answer("Location not found.", show_alert=True) + return + + action_obj = location.get_interactable(instance_id).get_action(action_id) + + if player['stamina'] < action_obj.stamina_cost: + await query.answer("You are too tired to do that!", show_alert=False) + return + + await query.answer() + + # Set cooldown + await database.set_cooldown(cooldown_key) + + # Resolve action + outcome = logic.resolve_action(player, action_obj) + new_stamina = player['stamina'] - action_obj.stamina_cost + new_hp = player['hp'] - outcome.damage_taken + await database.update_player(user_id, {"stamina": new_stamina, "hp": new_hp}) + + # Build detailed action result + result_details = [f"{outcome.text}"] + + if action_obj.stamina_cost > 0: + result_details.append(f"โšก๏ธ Stamina: -{action_obj.stamina_cost}") + + if outcome.damage_taken > 0: + result_details.append(f"โค๏ธ HP: -{outcome.damage_taken}") + + # Add items gained + if outcome.items_reward: + items_text = [] + items_failed = [] + for item_id, quantity in outcome.items_reward.items(): + can_add, reason = await logic.can_add_item_to_inventory(user_id, item_id, quantity) + + if can_add: + await database.add_item_to_inventory(user_id, item_id, quantity) + item_def = ITEMS.get(item_id, {}) + emoji = item_def.get('emoji', 'โ”') + item_name = item_def.get('name', item_id) + items_text.append(f"{emoji} {item_name} x{quantity}") + else: + item_def = ITEMS.get(item_id, {}) + item_name = item_def.get('name', item_id) + items_failed.append(f"{item_name} ({reason})") + + if items_text: + result_details.append(f"๐ŸŽ Gained: {', '.join(items_text)}") + if items_failed: + result_details.append(f"โš ๏ธ Couldn't take: {', '.join(items_failed)}") + + final_text = await get_player_status_text(user_id) + final_text += f"\n\nโ”โ”โ” Action Result โ”โ”โ”\n" + "\n".join(result_details) + + # Get location image for the result screen + current_location = game_world.get_location(player['location_id']) + location_image = current_location.image_path if current_location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=final_text, + reply_markup=keyboards.main_menu_keyboard(), + image_path=location_image + ) + + +# ============================================================================ +# NAVIGATION & MOVEMENT HANDLERS +# ============================================================================ + +async def handle_main_menu(query, user_id: int, player: dict): + """Return to main menu.""" + await query.answer() + status_text = await get_player_status_text(user_id) + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=status_text, + reply_markup=keyboards.main_menu_keyboard(), + image_path=location_image + ) + + +async def handle_move_menu(query, user_id: int, player: dict): + """Show movement options.""" + await query.answer() + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text="Where do you want to go?", + reply_markup=await keyboards.move_keyboard(player['location_id'], user_id), + image_path=location_image + ) + + +async def handle_move(query, user_id: int, player: dict, data: list): + """Handle player movement to a new location.""" + destination_id = data[1] + + from_location = game_world.get_location(player['location_id']) + to_location = game_world.get_location(destination_id) + + if not from_location or not to_location: + await query.answer("Invalid location!", show_alert=True) + return + + # Calculate stamina cost + inventory = await database.get_inventory(user_id) + stamina_cost = logic.calculate_travel_stamina_cost(player, inventory, from_location, to_location) + + if player['stamina'] < stamina_cost: + await query.answer(f"Too tired to travel! Need {stamina_cost} stamina.", show_alert=True) + return + + # Deduct stamina and update location + new_stamina = player['stamina'] - stamina_cost + await database.update_player(user_id, {"location_id": destination_id, "stamina": new_stamina}) + + await query.answer(f"โšก๏ธ -{stamina_cost} stamina", show_alert=False) + + # Refresh player data + player = await database.get_player(user_id) + + # Check for random NPC encounter + from data.npcs import NPCS, get_random_npc_for_location, get_location_encounter_rate + encounter_rate = get_location_encounter_rate(destination_id) + + if random.random() < encounter_rate: + from bot import combat + logger.info(f"Encounter triggered at {destination_id} (rate: {encounter_rate})") + + npc_id = get_random_npc_for_location(destination_id) + + if npc_id: + combat_data = await combat.initiate_combat(user_id, npc_id, destination_id) + + if combat_data: + npc_def = NPCS.get(npc_id) + message = f"โš ๏ธ A {npc_def.emoji} {npc_def.name} appears!\n\n" + message += f"{npc_def.description}\n\n" + message += f"{npc_def.emoji} Enemy HP: {combat_data['npc_hp']}/{combat_data['npc_max_hp']}\n" + message += f"โค๏ธ Your HP: {player['hp']}/{player['max_hp']}\n\n" + message += "๐ŸŽฏ Your turn! What will you do?" + + keyboard = await keyboards.combat_keyboard(user_id) + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=message, + reply_markup=keyboard, + image_path=npc_def.image_url if npc_def else None + ) + return + + status_text = await get_player_status_text(user_id) + new_location = game_world.get_location(destination_id) + location_image = new_location.image_path if new_location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=status_text, + reply_markup=keyboards.main_menu_keyboard(), + image_path=location_image + ) diff --git a/bot/combat_handlers.py b/bot/combat_handlers.py new file mode 100644 index 0000000..085dcd0 --- /dev/null +++ b/bot/combat_handlers.py @@ -0,0 +1,171 @@ +""" +Combat-related action handlers. +""" +import logging +from . import database, keyboards +from data.world_loader import game_world + +logger = logging.getLogger(__name__) + + +async def handle_combat_attack(query, user_id: int, player: dict): + """Handle player attack action in combat.""" + from bot import combat + await query.answer() + + message, npc_died, turn_ended = await combat.player_attack(user_id) + + if npc_died: + # Combat ended - return to main menu + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=message, + reply_markup=keyboards.main_menu_keyboard(), + image_path=location_image + ) + elif turn_ended: + # NPC's turn - auto-attack + npc_message, player_died = await combat.npc_attack(user_id) + message += "\n\n" + npc_message + + if player_died: + from .handlers import send_or_edit_with_image + await send_or_edit_with_image(query, text=message, reply_markup=None) + else: + combat_data = await database.get_combat(user_id) + if combat_data: + from data.npcs import NPCS + npc_def = NPCS.get(combat_data['npc_id']) + keyboard = await keyboards.combat_keyboard(user_id) + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=message, + reply_markup=keyboard, + image_path=npc_def.image_url if npc_def else None + ) + else: + await query.answer(message, show_alert=False) + + +async def handle_combat_flee(query, user_id: int, player: dict): + """Handle flee attempt in combat.""" + from bot import combat + await query.answer() + + message, fled, turn_ended = await combat.flee_attempt(user_id) + + if fled: + # Successfully fled - return to main menu + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=message, + reply_markup=keyboards.main_menu_keyboard(), + image_path=location_image + ) + elif turn_ended: + # Failed to flee - NPC attacks + npc_message, player_died = await combat.npc_attack(user_id) + message += "\n\n" + npc_message + + if player_died: + from .handlers import send_or_edit_with_image + await send_or_edit_with_image(query, text=message, reply_markup=None) + else: + combat_data = await database.get_combat(user_id) + if combat_data: + from data.npcs import NPCS + npc_def = NPCS.get(combat_data['npc_id']) + keyboard = await keyboards.combat_keyboard(user_id) + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=message, + reply_markup=keyboard, + image_path=npc_def.image_url if npc_def else None + ) + else: + await query.answer(message, show_alert=False) + + +async def handle_combat_use_item_menu(query, user_id: int, player: dict): + """Show menu of items that can be used in combat.""" + await query.answer() + keyboard = await keyboards.combat_items_keyboard(user_id) + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text="๐Ÿ’Š Select an item to use:", + reply_markup=keyboard + ) + + +async def handle_combat_use_item(query, user_id: int, player: dict, data: list): + """Use an item during combat.""" + from bot import combat + item_db_id = int(data[1]) + + message, turn_ended = await combat.use_item_in_combat(user_id, item_db_id) + await query.answer(message, show_alert=False) + + if turn_ended: + # NPC's turn + npc_message, player_died = await combat.npc_attack(user_id) + + if player_died: + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=message + "\n\n" + npc_message, + reply_markup=None + ) + else: + combat_data = await database.get_combat(user_id) + if combat_data: + from data.npcs import NPCS + npc_def = NPCS.get(combat_data['npc_id']) + keyboard = await keyboards.combat_keyboard(user_id) + full_message = message + "\n\n" + npc_message + "\n\n๐ŸŽฏ Your turn!" + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=full_message, + reply_markup=keyboard, + image_path=npc_def.image_url if npc_def else None + ) + + +async def handle_combat_back(query, user_id: int, player: dict): + """Return to combat menu from item selection.""" + await query.answer() + combat_data = await database.get_combat(user_id) + + if combat_data: + from data.npcs import NPCS + npc_def = NPCS.get(combat_data['npc_id']) + keyboard = await keyboards.combat_keyboard(user_id) + + message = f"โš”๏ธ Combat with {npc_def.emoji} {npc_def.name}!\n" + message += f"{npc_def.emoji} Enemy HP: {combat_data['npc_hp']}/{combat_data['npc_max_hp']}\n" + message += f"โค๏ธ Your HP: {player['hp']}/{player['max_hp']}\n\n" + message += "๐ŸŽฏ Your turn!" if combat_data['turn'] == 'player' else "โณ Enemy's turn..." + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=message, + reply_markup=keyboard, + image_path=npc_def.image_url if npc_def else None + ) diff --git a/bot/corpse_handlers.py b/bot/corpse_handlers.py new file mode 100644 index 0000000..c065eb7 --- /dev/null +++ b/bot/corpse_handlers.py @@ -0,0 +1,234 @@ +""" +Corpse looting handlers (player and NPC corpses). +""" +import logging +import json +import random +from . import database, keyboards, logic +from data.world_loader import game_world +from data.items import ITEMS + +logger = logging.getLogger(__name__) + + +async def handle_loot_player_corpse(query, user_id: int, player: dict, data: list): + """Show player corpse loot menu.""" + corpse_id = int(data[1]) + corpse = await database.get_player_corpse(corpse_id) + + if not corpse: + await query.answer("Corpse not found.", show_alert=False) + return + + items = json.loads(corpse['items']) + keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items) + + location = game_world.get_location(player['location_id']) + image_path = location.image_path if location else None + + await query.answer() + text = f"๐ŸŽ’ {corpse['player_name']}'s bag\n\nYou find the remains of another survivor..." + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=text, + reply_markup=keyboard, + image_path=image_path + ) + + +async def handle_take_corpse_item(query, user_id: int, player: dict, data: list): + """Take an item from a player corpse.""" + corpse_id = int(data[1]) + item_index = int(data[2]) + + corpse = await database.get_player_corpse(corpse_id) + if not corpse: + await query.answer("Corpse not found.", show_alert=False) + return + + items = json.loads(corpse['items']) + if item_index >= len(items): + await query.answer("Item not found.", show_alert=False) + return + + item_data = items[item_index] + item_def = ITEMS.get(item_data['item_id'], {}) + + # Check inventory capacity + can_add, reason = await logic.can_add_item_to_inventory( + user_id, item_data['item_id'], item_data['quantity'] + ) + + if not can_add: + await query.answer(reason, show_alert=False) + return + + # Add to inventory + await database.add_item_to_inventory(user_id, item_data['item_id'], item_data['quantity']) + + # Remove from corpse + items.pop(item_index) + + if items: + await database.update_player_corpse(corpse_id, json.dumps(items)) + keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items) + + location = game_world.get_location(player['location_id']) + image_path = location.image_path if location else None + + await query.answer(f"Took {item_def.get('name', 'Unknown')}.", show_alert=False) + text = f"๐ŸŽ’ {corpse['player_name']}'s bag" + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=text, + reply_markup=keyboard, + image_path=image_path + ) + else: + # Bag is empty, remove it + await database.remove_player_corpse(corpse_id) + await query.answer( + f"Took {item_def.get('name', 'Unknown')}. The bag is now empty.", + show_alert=False + ) + + location = game_world.get_location(player['location_id']) + dropped_items = await database.get_dropped_items_in_location(player['location_id']) + wandering_enemies = await database.get_wandering_enemies_in_location(player['location_id']) + keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items, wandering_enemies) + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text="You scan the area. You notice...", + reply_markup=keyboard, + image_path=location.image_path if location else None + ) + + +async def handle_scavenge_npc_corpse(query, user_id: int, player: dict, data: list): + """Show NPC corpse scavenging menu.""" + corpse_id = int(data[1]) + corpse = await database.get_npc_corpse(corpse_id) + + if not corpse: + await query.answer("Corpse not found.", show_alert=False) + return + + from data.npcs import NPCS + npc_def = NPCS.get(corpse['npc_id']) + loot_items = json.loads(corpse['loot_remaining']) + keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items) + + location = game_world.get_location(player['location_id']) + image_path = location.image_path if location else None + + await query.answer() + text = f"๐Ÿ”ช {npc_def.emoji} {npc_def.name} Corpse\n\n{npc_def.description}" + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=text, + reply_markup=keyboard, + image_path=image_path + ) + + +async def handle_scavenge_corpse_item(query, user_id: int, player: dict, data: list): + """Scavenge a specific item from an NPC corpse.""" + corpse_id = int(data[1]) + loot_index = int(data[2]) + + corpse = await database.get_npc_corpse(corpse_id) + if not corpse: + await query.answer("Corpse not found.", show_alert=False) + return + + loot_items = json.loads(corpse['loot_remaining']) + if loot_index >= len(loot_items): + await query.answer("Nothing to scavenge here.", show_alert=False) + return + + loot_data = loot_items[loot_index] + required_tool = loot_data.get('required_tool') + + # Check if player has required tool + if required_tool: + inventory_items = await database.get_inventory(user_id) + has_tool = any(item['item_id'] == required_tool for item in inventory_items) + + if not has_tool: + tool_def = ITEMS.get(required_tool, {}) + await query.answer( + f"You need a {tool_def.get('name', 'tool')} to scavenge this.", + show_alert=False + ) + return + + # Determine quantity + quantity = random.randint(loot_data['quantity_min'], loot_data['quantity_max']) + item_def = ITEMS.get(loot_data['item_id'], {}) + + # Check inventory capacity + can_add, reason = await logic.can_add_item_to_inventory( + user_id, loot_data['item_id'], quantity + ) + + if not can_add: + await query.answer(reason, show_alert=False) + return + + # Add to inventory + await database.add_item_to_inventory(user_id, loot_data['item_id'], quantity) + + # Remove from corpse + loot_items.pop(loot_index) + + if loot_items: + await database.update_npc_corpse(corpse_id, json.dumps(loot_items)) + keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items) + + location = game_world.get_location(player['location_id']) + image_path = location.image_path if location else None + + await query.answer( + f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}.", + show_alert=False + ) + + from data.npcs import NPCS + npc_def = NPCS.get(corpse['npc_id']) + text = f"๐Ÿ”ช {npc_def.emoji} {npc_def.name} Corpse" + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=text, + reply_markup=keyboard, + image_path=image_path + ) + else: + # Nothing left, remove corpse + await database.remove_npc_corpse(corpse_id) + await query.answer( + f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}. Nothing left on the corpse.", + show_alert=False + ) + + location = game_world.get_location(player['location_id']) + dropped_items = await database.get_dropped_items_in_location(player['location_id']) + wandering_enemies = await database.get_wandering_enemies_in_location(player['location_id']) + keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items, wandering_enemies) + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text="You scan the area. You notice...", + reply_markup=keyboard, + image_path=location.image_path if location else None + ) diff --git a/bot/handlers.py b/bot/handlers.py index 428205c..87e970f 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -1,53 +1,73 @@ +""" +Main handlers for the Telegram bot. +This module contains the core message routing and utility functions. +All specific action handlers are organized in separate modules. +""" import logging -import math +import os import json -import random -from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton +from telegram import Update, InlineKeyboardMarkup, InputMediaPhoto from telegram.ext import ContextTypes from telegram.error import BadRequest -from . import database, keyboards, logic +from . import database, keyboards from .utils import admin_only from data.world_loader import game_world -from data.items import ITEMS + +# Import organized action handlers +from .action_handlers import ( + get_player_status_text, + handle_inspect_area, + handle_attack_wandering, + handle_inspect_interactable, + handle_action, + handle_main_menu, + handle_move_menu, + handle_move +) +from .inventory_handlers import ( + handle_inventory_menu, + handle_inventory_item, + handle_inventory_use, + handle_inventory_drop, + handle_inventory_equip, + handle_inventory_unequip +) +from .pickup_handlers import ( + handle_pickup_menu, + handle_pickup +) +from .combat_handlers import ( + handle_combat_attack, + handle_combat_flee, + handle_combat_use_item_menu, + handle_combat_use_item, + handle_combat_back +) +from .profile_handlers import ( + handle_profile, + handle_spend_points_menu, + handle_spend_point +) +from .corpse_handlers import ( + handle_loot_player_corpse, + handle_take_corpse_item, + handle_scavenge_npc_corpse, + handle_scavenge_corpse_item +) logger = logging.getLogger(__name__) -# ... (get_player_status_text, send_or_edit_with_image, start are unchanged) ... -async def get_player_status_text(telegram_id: int) -> str: - player = await database.get_player(telegram_id) - if not player: return "Could not find player data." - location = game_world.get_location(player["location_id"]) - if not location: return "Error: Player is in an unknown location." - inventory = await database.get_inventory(telegram_id) - weight, volume = logic.calculate_inventory_load(inventory) - max_weight, max_volume = logic.get_player_capacity(inventory, player) - - # Get equipped items - equipped_items = [] - for item in inventory: - if item.get('is_equipped'): - item_def = ITEMS.get(item['item_id'], {}) - emoji = item_def.get('emoji', 'โ”') - equipped_items.append(f"{emoji} {item_def.get('name', 'Unknown')}") - - status = f"Location: {location.name}\nStatus: Healthy\n" - status += f"โค๏ธ HP: {player['hp']}/{player['max_hp']} | โšก๏ธ Stamina: {player['stamina']}/{player['max_stamina']}\n" - status += f"๐ŸŽ’ Load: {weight}/{max_weight} kg | {volume}/{max_volume} vol\n" - - if equipped_items: - status += f"โš”๏ธ Equipped: {', '.join(equipped_items)}\n" - - status += f"โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n{location.description}" - return status -async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboardMarkup, image_path: str = None, parse_mode='HTML'): + +# ============================================================================ +# UTILITY FUNCTIONS +# ============================================================================ + +async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboardMarkup, + image_path: str = None, parse_mode='HTML'): """ Send a message with an image (as caption) or edit existing message. Uses edit_message_media for smooth transitions when changing images. """ - import os - from telegram import InputMediaPhoto - - # Check if we should edit or send new current_message = query.message has_photo = bool(current_message.photo) @@ -94,7 +114,6 @@ async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboard except BadRequest as e: if "Message is not modified" in str(e): return - # Failed to edit, fall through to send new else: # Different image - use edit_message_media for smooth transition try: @@ -110,11 +129,9 @@ async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboard return except Exception as e: logger.error(f"Error editing message media: {e}") - # Fall through to delete and send new - # Current message has no photo - try to edit to photo + # Current message has no photo - need to delete and send new if not has_photo: - # Can't edit text message to photo message, need to delete and send new try: await current_message.delete() except: @@ -145,13 +162,22 @@ async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboard except BadRequest as e: if "Message is not modified" not in str(e): await current_message.reply_html(text=text, reply_markup=reply_markup) + + +# ============================================================================ +# COMMAND HANDLERS +# ============================================================================ + async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - import os + """Handle /start command - initialize or show player status.""" user = update.effective_user player = await database.get_player(user.id) + if not player: await database.create_player(user.id, user.first_name) - await update.message.reply_html(f"Welcome, {user.mention_html()}! Your story is just beginning.") + await update.message.reply_html( + f"Welcome, {user.mention_html()}! Your story is just beginning." + ) # Get player status and location image player = await database.get_player(user.id) @@ -179,21 +205,27 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if msg.photo: await database.cache_image(location.image_path, msg.photo[-1].file_id) else: - await update.message.reply_html(status_text, reply_markup=keyboards.main_menu_keyboard()) + await update.message.reply_html( + status_text, + reply_markup=keyboards.main_menu_keyboard() + ) else: - await update.message.reply_html(status_text, reply_markup=keyboards.main_menu_keyboard()) + await update.message.reply_html( + status_text, + reply_markup=keyboards.main_menu_keyboard() + ) + @admin_only async def export_map(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Export map data as JSON for external visualization.""" from data.world_loader import export_map_data - import json + from io import BytesIO map_data = export_map_data() json_str = json.dumps(map_data, indent=2) # Send as text file - from io import BytesIO file = BytesIO(json_str.encode('utf-8')) file.name = "map_data.json" @@ -217,7 +249,6 @@ async def spawn_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non if stats['by_location']: text += "Enemies by Location:\n" for loc_id, count in stats['by_location'].items(): - from data.world_loader import game_world location = game_world.get_location(loc_id) loc_name = location.name if location else loc_id text += f"โ€ข {loc_name}: {count}\n" @@ -226,7 +257,16 @@ async def spawn_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non await update.message.reply_html(text) + +# ============================================================================ +# BUTTON CALLBACK ROUTER +# ============================================================================ + async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """ + Main router for button callbacks. + Delegates to specific handler functions based on action type. + """ query = update.callback_query user_id = query.from_user.id data = query.data.split(':') @@ -235,1034 +275,103 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> player = await database.get_player(user_id) if not player or player['is_dead']: await query.answer() - await send_or_edit_with_image(query, text="๐Ÿ’€ Your journey has ended. You died in the wasteland. Create a new character with /start to begin again.", reply_markup=None) + await send_or_edit_with_image( + query, + text="๐Ÿ’€ Your journey has ended. You died in the wasteland. Create a new character with /start to begin again.", + reply_markup=None + ) return # Check if player is in combat - restrict most actions combat = await database.get_combat(user_id) - if combat and action_type not in ['combat_attack', 'combat_flee', 'combat_use_item_menu', 'combat_use_item', 'combat_back', 'no_op']: + allowed_in_combat = [ + 'combat_attack', 'combat_flee', 'combat_use_item_menu', + 'combat_use_item', 'combat_back', 'no_op' + ] + if combat and action_type not in allowed_in_combat: await query.answer("You're in combat! Focus on the fight!", show_alert=False) return - # --- Inspection & World Interaction --- - if action_type == "inspect_area": - await query.answer() - location_id = player['location_id'] - location = game_world.get_location(location_id) - dropped_items = await database.get_dropped_items_in_location(location_id) + # Route to appropriate handler based on action type + try: + # Inspection & World Interaction + if action_type == "inspect_area": + await handle_inspect_area(query, user_id, player) + elif action_type == "attack_wandering": + await handle_attack_wandering(query, user_id, player, data) + elif action_type == "inspect": + await handle_inspect_interactable(query, user_id, player, data) + elif action_type == "action": + await handle_action(query, user_id, player, data) + elif action_type == "inspect_area_menu": + await handle_inspect_area(query, user_id, player) - # Get wandering enemies from database - wandering_enemies = await database.get_wandering_enemies_in_location(location_id) + # Navigation & Menu + elif action_type == "main_menu": + await handle_main_menu(query, user_id, player) + elif action_type == "move_menu": + await handle_move_menu(query, user_id, player) + elif action_type == "move": + await handle_move(query, user_id, player, data) - keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) - image_path = location.image_path if location else None - await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) - - elif action_type == "attack_wandering": - # Player initiates combat with a wandering enemy - enemy_db_id = int(data[1]) - await query.answer() + # Profile & Stats + elif action_type == "profile": + await handle_profile(query, user_id, player) + elif action_type == "spend_points_menu": + await handle_spend_points_menu(query, user_id, player) + elif action_type == "spend_point": + await handle_spend_point(query, user_id, player, data) - # Get the enemy from database - wandering_enemies = await database.get_wandering_enemies_in_location(player['location_id']) - enemy_data = next((e for e in wandering_enemies if e['id'] == enemy_db_id), None) + # Inventory Management + elif action_type == "inventory_menu": + await handle_inventory_menu(query, user_id, player) + elif action_type == "inventory_item": + await handle_inventory_item(query, user_id, player, data) + elif action_type == "inventory_use": + await handle_inventory_use(query, user_id, player, data) + elif action_type == "inventory_drop": + await handle_inventory_drop(query, user_id, player, data) + elif action_type == "inventory_equip": + await handle_inventory_equip(query, user_id, player, data) + elif action_type == "inventory_unequip": + await handle_inventory_unequip(query, user_id, player, data) - if not enemy_data: - await query.answer("That enemy has already moved on!", show_alert=True) - # Refresh inspect menu - location_id = player['location_id'] - location = game_world.get_location(location_id) - dropped_items = await database.get_dropped_items_in_location(location_id) - wandering_enemies = await database.get_wandering_enemies_in_location(location_id) - keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) - image_path = location.image_path if location else None - await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) - return + # Item Pickup + elif action_type == "pickup_menu": + await handle_pickup_menu(query, user_id, player, data) + elif action_type == "pickup": + await handle_pickup(query, user_id, player, data) - npc_id = enemy_data['npc_id'] + # Combat Actions + elif action_type == "combat_attack": + await handle_combat_attack(query, user_id, player) + elif action_type == "combat_flee": + await handle_combat_flee(query, user_id, player) + elif action_type == "combat_use_item_menu": + await handle_combat_use_item_menu(query, user_id, player) + elif action_type == "combat_use_item": + await handle_combat_use_item(query, user_id, player, data) + elif action_type == "combat_back": + await handle_combat_back(query, user_id, player) - # Remove enemy from wandering table (they're now in combat) - await database.remove_wandering_enemy(enemy_db_id) + # Corpse Looting + elif action_type == "loot_player_corpse": + await handle_loot_player_corpse(query, user_id, player, data) + elif action_type == "take_corpse_item": + await handle_take_corpse_item(query, user_id, player, data) + elif action_type == "scavenge_npc_corpse": + await handle_scavenge_npc_corpse(query, user_id, player, data) + elif action_type == "scavenge_corpse_item": + await handle_scavenge_corpse_item(query, user_id, player, data) - from data.npcs import NPCS - from bot import combat + # No-op (for disabled buttons) + elif action_type == "no_op": + await query.answer() - # Initiate combat with from_wandering_enemy=True so it respawns on flee/death - combat_data = await combat.initiate_combat(user_id, npc_id, player['location_id'], from_wandering_enemy=True) - - if combat_data: - npc_def = NPCS.get(npc_id) - message = f"โš”๏ธ You engage the {npc_def.emoji} {npc_def.name}!\n\n" - message += f"{npc_def.description}\n\n" - message += f"{npc_def.emoji} Enemy HP: {combat_data['npc_hp']}/{combat_data['npc_max_hp']}\n" - message += f"โค๏ธ Your HP: {player['hp']}/{player['max_hp']}\n\n" - message += "๐ŸŽฏ Your turn! What will you do?" - - keyboard = await keyboards.combat_keyboard(user_id) - await send_or_edit_with_image(query, text=message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) else: - await query.answer("Failed to initiate combat.", show_alert=True) - - elif action_type == "inspect": - location_id, instance_id = data[1], data[2] - - location = game_world.get_location(location_id) - - if not location: - await query.answer("Location not found.", show_alert=True) - return - - interactable = location.get_interactable(instance_id) - - if not interactable: - await query.answer("Object not found.", show_alert=False) - return - - # Check if ALL actions are on cooldown - all_on_cooldown = True - for action_id in interactable.actions.keys(): - cooldown_key = f"{instance_id}:{action_id}" - if await database.get_cooldown(cooldown_key) == 0: - all_on_cooldown = False - break - - if all_on_cooldown and len(interactable.actions) > 0: - await query.answer(f"The {interactable.name} has already been searched. Try again later.", show_alert=False) - return - - # Show action menu - await query.answer() - image_path = interactable.image_path if interactable else None - await send_or_edit_with_image(query, text=f"You focus on the {interactable.name}. What do you do?", reply_markup=await keyboards.actions_keyboard(location_id, instance_id), image_path=image_path) - - elif action_type == "action": - location_id, instance_id, action_id = data[1], data[2], data[3] - cooldown_key = f"{instance_id}:{action_id}" - cooldown = await database.get_cooldown(cooldown_key) - if cooldown > 0: - await query.answer(f"Someone got to it just before you!", show_alert=False) - return - - location = game_world.get_location(location_id) - - if not location: - await query.answer("Location not found.", show_alert=True) - return - - action_obj = location.get_interactable(instance_id).get_action(action_id) - - if player['stamina'] < action_obj.stamina_cost: - await query.answer("You are too tired to do that!", show_alert=False) - return - - # Answer the callback to dismiss loading state - await query.answer() - - # FIX: Set cooldown ON ACTION, not after result. - await database.set_cooldown(cooldown_key) - - outcome = logic.resolve_action(player, action_obj) - new_stamina = player['stamina'] - action_obj.stamina_cost - new_hp = player['hp'] - outcome.damage_taken - await database.update_player(user_id, {"stamina": new_stamina, "hp": new_hp}) - - # Build detailed action result - result_details = [] - - # Add the outcome text - result_details.append(f"{outcome.text}") - - # Add stamina cost - if action_obj.stamina_cost > 0: - result_details.append(f"โšก๏ธ Stamina: -{action_obj.stamina_cost}") - - # Add HP damage if any - if outcome.damage_taken > 0: - result_details.append(f"โค๏ธ HP: -{outcome.damage_taken}") - - # Add items gained - if outcome.items_reward: - items_text = [] - items_failed = [] - for item_id, quantity in outcome.items_reward.items(): - # Check if item can be added - can_add, reason = await logic.can_add_item_to_inventory(user_id, item_id, quantity) - - if can_add: - await database.add_item_to_inventory(user_id, item_id, quantity) - item_def = ITEMS.get(item_id, {}) - emoji = item_def.get('emoji', 'โ”') - item_name = item_def.get('name', item_id) - items_text.append(f"{emoji} {item_name} x{quantity}") - else: - item_def = ITEMS.get(item_id, {}) - item_name = item_def.get('name', item_id) - items_failed.append(f"{item_name} ({reason})") - - if items_text: - result_details.append(f"๐ŸŽ Gained: {', '.join(items_text)}") - if items_failed: - result_details.append(f"โš ๏ธ Couldn't take: {', '.join(items_failed)}") - - final_text = await get_player_status_text(user_id) - final_text += f"\n\nโ”โ”โ” Action Result โ”โ”โ”\n" + "\n".join(result_details) - - # Get location image for the result screen - current_location = game_world.get_location(player['location_id']) - location_image = current_location.image_path if current_location else None - await send_or_edit_with_image(query, text=final_text, reply_markup=keyboards.main_menu_keyboard(), image_path=location_image) + logger.warning(f"Unknown action type: {action_type}") + await query.answer("Unknown action", show_alert=False) - # ... (Other handlers like pickup, inventory, move are mostly unchanged) ... - elif action_type == "main_menu": - await query.answer() - status_text = await get_player_status_text(user_id) - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text=status_text, reply_markup=keyboards.main_menu_keyboard(), image_path=location_image) - - elif action_type == "profile": - await query.answer() - from bot import combat - - # Calculate stats - xp_current = player['xp'] - xp_needed = combat.xp_for_level(player['level'] + 1) - xp_for_current_level = combat.xp_for_level(player['level']) - xp_progress = max(0, xp_current - xp_for_current_level) # Ensure non-negative - xp_level_requirement = xp_needed - xp_for_current_level - progress_percent = int((xp_progress / xp_level_requirement) * 100) if xp_level_requirement > 0 else 0 - - unspent = player.get('unspent_points', 0) - - profile_text = f"๐Ÿ‘ค {player['name']}\n" - profile_text += f"โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n\n" - profile_text += f"Level: {player['level']}\n" - profile_text += f"XP: {xp_current}/{xp_needed} ({progress_percent}%)\n" - - if unspent > 0: - profile_text += f"โญ Unspent Points: {unspent}\n" - - profile_text += f"\nHealth: {player['hp']}/{player['max_hp']} โค๏ธ\n" - profile_text += f"Stamina: {player['stamina']}/{player['max_stamina']} โšก\n\n" - profile_text += f"Stats:\n" - profile_text += f"๐Ÿ’ช Strength: {player['strength']}\n" - profile_text += f"๐Ÿƒ Agility: {player['agility']}\n" - profile_text += f"๐Ÿ’š Endurance: {player['endurance']}\n" - profile_text += f"๐Ÿง  Intellect: {player['intellect']}\n\n" - profile_text += f"Combat:\n" - profile_text += f"โš”๏ธ Base Damage: {5 + player['strength'] // 2 + player['level']}\n" - profile_text += f"๐Ÿ›ก๏ธ Flee Chance: {int((0.5 + player['agility'] / 100) * 100)}%\n" - profile_text += f"๐Ÿ’š Stamina Regen: {1 + player['endurance'] // 10}/cycle\n" - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - - # Add spend points button if player has unspent points - keyboard_buttons = [] - if unspent > 0: - keyboard_buttons.append([InlineKeyboardButton("โญ Spend Stat Points", callback_data="spend_points_menu")]) - keyboard_buttons.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data="main_menu")]) - back_keyboard = InlineKeyboardMarkup(keyboard_buttons) - - await send_or_edit_with_image(query, text=profile_text, reply_markup=back_keyboard, image_path=location_image) - - elif action_type == "spend_points_menu": - await query.answer() - unspent = player.get('unspent_points', 0) - - if unspent <= 0: - await query.answer("You have no points to spend!", show_alert=False) - return - - text = f"โญ Spend Stat Points\n\n" - text += f"Available Points: {unspent}\n\n" - text += f"Current Stats:\n" - text += f"โค๏ธ Max HP: {player['max_hp']} (+10 per point)\n" - text += f"โšก Max Stamina: {player['max_stamina']} (+5 per point)\n" - text += f"๐Ÿ’ช Strength: {player['strength']} (+1 per point)\n" - text += f"๐Ÿƒ Agility: {player['agility']} (+1 per point)\n" - text += f"๐Ÿ’š Endurance: {player['endurance']} (+1 per point)\n" - text += f"๐Ÿง  Intellect: {player['intellect']} (+1 per point)\n\n" - text += f"๐Ÿ’ก Choose wisely! Each point matters." - - keyboard = keyboards.spend_points_keyboard() - await send_or_edit_with_image(query, text=text, reply_markup=keyboard) - - elif action_type == "spend_point": - stat_name = data[1] - unspent = player.get('unspent_points', 0) - - if unspent <= 0: - await query.answer("You have no points to spend!", show_alert=False) - return - - # Map stat names to updates - stat_mapping = { - 'max_hp': ('max_hp', 10, 'โค๏ธ Max HP'), - 'max_stamina': ('max_stamina', 5, 'โšก Max Stamina'), - 'strength': ('strength', 1, '๐Ÿ’ช Strength'), - 'agility': ('agility', 1, '๐Ÿƒ Agility'), - 'endurance': ('endurance', 1, '๐Ÿ’š Endurance'), - 'intellect': ('intellect', 1, '๐Ÿง  Intellect'), - } - - if stat_name not in stat_mapping: - await query.answer("Invalid stat!", show_alert=False) - return - - db_field, increase, display_name = stat_mapping[stat_name] - new_value = player[db_field] + increase - new_unspent = unspent - 1 - - await database.update_player(user_id, { - db_field: new_value, - 'unspent_points': new_unspent - }) - - # Update local player data - player[db_field] = new_value - player['unspent_points'] = new_unspent - - await query.answer(f"+{increase} {display_name}!", show_alert=False) - - # Refresh the spend points menu - text = f"โญ Spend Stat Points\n\n" - text += f"Available Points: {new_unspent}\n\n" - text += f"Current Stats:\n" - text += f"โค๏ธ Max HP: {player['max_hp']} (+10 per point)\n" - text += f"โšก Max Stamina: {player['max_stamina']} (+5 per point)\n" - text += f"๐Ÿ’ช Strength: {player['strength']} (+1 per point)\n" - text += f"๐Ÿƒ Agility: {player['agility']} (+1 per point)\n" - text += f"๐Ÿ’š Endurance: {player['endurance']} (+1 per point)\n" - text += f"๐Ÿง  Intellect: {player['intellect']} (+1 per point)\n\n" - text += f"๐Ÿ’ก Choose wisely! Each point matters." - - keyboard = keyboards.spend_points_keyboard() - await send_or_edit_with_image(query, text=text, reply_markup=keyboard) - - elif action_type == "move_menu": - await query.answer() - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text="Where do you want to go?", reply_markup=await keyboards.move_keyboard(player['location_id'], user_id), image_path=location_image) - elif action_type == "move": - destination_id = data[1] - - # Get locations for distance calculation - from_location = game_world.get_location(player['location_id']) - to_location = game_world.get_location(destination_id) - - if not from_location or not to_location: - await query.answer("Invalid location!", show_alert=True) - return - - # Calculate stamina cost for travel - inventory = await database.get_inventory(user_id) - stamina_cost = logic.calculate_travel_stamina_cost(player, inventory, from_location, to_location) - - # Check if player has enough stamina - if player['stamina'] < stamina_cost: - await query.answer(f"Too tired to travel! Need {stamina_cost} stamina.", show_alert=True) - return - - # Deduct stamina and update location - new_stamina = player['stamina'] - stamina_cost - await database.update_player(user_id, {"location_id": destination_id, "stamina": new_stamina}) - - await query.answer(f"โšก๏ธ -{stamina_cost} stamina", show_alert=False) - - # Refresh player data after update - player = await database.get_player(user_id) - - # Check for random NPC encounter (dynamic chance based on destination danger) - from data.npcs import NPCS, get_random_npc_for_location, get_location_encounter_rate - encounter_rate = get_location_encounter_rate(destination_id) - - if random.random() < encounter_rate: - from bot import combat - logging.info(f"Encounter triggered at {destination_id} (rate: {encounter_rate})") - # Select random NPC appropriate for this location - npc_id = get_random_npc_for_location(destination_id) - - # If location has spawns and NPC was selected, initiate combat - if npc_id: - combat_data = await combat.initiate_combat(user_id, npc_id, destination_id) - - if combat_data: - npc_def = NPCS.get(npc_id) - message = f"โš ๏ธ A {npc_def.emoji} {npc_def.name} appears!\n\n" - message += f"{npc_def.description}\n\n" - message += f"{npc_def.emoji} Enemy HP: {combat_data['npc_hp']}/{combat_data['npc_max_hp']}\n" - message += f"โค๏ธ Your HP: {player['hp']}/{player['max_hp']}\n\n" - message += "๐ŸŽฏ Your turn! What will you do?" - - keyboard = await keyboards.combat_keyboard(user_id) - await send_or_edit_with_image(query, text=message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) - return - - status_text = await get_player_status_text(user_id) - new_location = game_world.get_location(destination_id) - location_image = new_location.image_path if new_location else None - await send_or_edit_with_image(query, text=status_text, reply_markup=keyboards.main_menu_keyboard(), image_path=location_image) - - elif action_type == "pickup_menu": - # Show pickup options for an item - dropped_item_id = int(data[1]) - item_to_pickup = await database.get_dropped_item(dropped_item_id) - - if not item_to_pickup: - await query.answer("Someone already picked that up!", show_alert=False) - location_id = player['location_id'] - location = game_world.get_location(location_id) - dropped_items = await database.get_dropped_items_in_location(location_id) - keyboard = await keyboards.inspect_keyboard(location_id, dropped_items) - image_path = location.image_path if location else None - await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) - return - - item_def = ITEMS.get(item_to_pickup['item_id'], {}) - emoji = item_def.get('emoji', 'โ”') - text = f"{emoji} {item_def.get('name', 'Unknown')}\n\n" - text += f"Available: {item_to_pickup['quantity']}\n" - text += f"Weight: {item_def.get('weight', 0)} kg each\n" - text += f"Volume: {item_def.get('volume', 0)} vol each\n\n" - text += "How many do you want to pick up?" - - await query.answer() - keyboard = keyboards.pickup_options_keyboard(dropped_item_id, item_def.get('name', 'Unknown'), item_to_pickup['quantity']) - # Keep location image for visual continuity - location = game_world.get_location(player['location_id']) - image_path = location.image_path if location else None - await send_or_edit_with_image(query, text=text, reply_markup=keyboard, image_path=image_path) - - elif action_type == "pickup": - dropped_item_id = int(data[1]) - pickup_amount_str = data[2] if len(data) > 2 else "all" - - item_to_pickup = await database.get_dropped_item(dropped_item_id) - if not item_to_pickup: - await query.answer("Someone already picked that up!", show_alert=False) - location_id = player['location_id'] - location = game_world.get_location(location_id) - dropped_items = await database.get_dropped_items_in_location(location_id) - keyboard = await keyboards.inspect_keyboard(location_id, dropped_items) - image_path = location.image_path if location else None - await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) - return - - # Determine how much to pick up - if pickup_amount_str == "all": - pickup_amount = item_to_pickup['quantity'] - else: - pickup_amount = min(int(pickup_amount_str), item_to_pickup['quantity']) - - # Check inventory capacity - can_add, reason = await logic.can_add_item_to_inventory(user_id, item_to_pickup['item_id'], pickup_amount) - - if not can_add: - await query.answer(reason, show_alert=True) - return - - # Add to inventory - await database.add_item_to_inventory(user_id, item_to_pickup['item_id'], pickup_amount) - - # Update or remove dropped item - remaining = item_to_pickup['quantity'] - pickup_amount - if remaining > 0: - # Update quantity - await database.update_dropped_item(dropped_item_id, remaining) - item_def = ITEMS.get(item_to_pickup['item_id'], {}) - await query.answer(f"Picked up {pickup_amount}x {item_def.get('name', 'item')}. {remaining} remaining.", show_alert=False) - else: - # Remove item completely - await database.remove_dropped_item(dropped_item_id) - item_def = ITEMS.get(item_to_pickup['item_id'], {}) - await query.answer(f"Picked up {pickup_amount}x {item_def.get('name', 'item')}.", show_alert=False) - - # Return to inspect area - location_id = player['location_id'] - location = game_world.get_location(location_id) - dropped_items = await database.get_dropped_items_in_location(location_id) - keyboard = await keyboards.inspect_keyboard(location_id, dropped_items) - image_path = location.image_path if location else None - await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) - - elif action_type == "inventory_menu": - await query.answer() - inventory_items = await database.get_inventory(user_id) - - # Calculate inventory summary - current_weight, current_volume = logic.calculate_inventory_load(inventory_items) - max_weight, max_volume = logic.get_player_capacity(inventory_items, player) - - text = "๐ŸŽ’ Your Inventory:\n" - text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" - text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n\n" - - if not inventory_items: - text += "It's empty." - - # Keep current location image for context - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_keyboard(inventory_items), image_path=location_image) - elif action_type == "inventory_item": - await query.answer() - item_db_id = int(data[1]) - item = await database.get_inventory_item(item_db_id) - item_def = ITEMS.get(item['item_id'], {}) - emoji = item_def.get('emoji', 'โ”') - - # Build item details text - text = f"{emoji} {item_def.get('name', 'Unknown')}\n" - - # Add description if available - description = item_def.get('description') - if description: - text += f"{description}\n\n" - else: - text += "\n" - - text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" - - # Add weapon stats if applicable - if item_def.get('type') == 'weapon': - text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" - - # Add consumable effects if applicable - if item_def.get('type') == 'consumable': - effects = [] - if item_def.get('hp_restore'): - effects.append(f"โค๏ธ +{item_def.get('hp_restore')} HP") - if item_def.get('stamina_restore'): - effects.append(f"โšก +{item_def.get('stamina_restore')} Stamina") - if effects: - text += f"Effects: {', '.join(effects)}\n" - - # Add equipped status - if item.get('is_equipped'): - text += "\nโœ… Currently Equipped" - - # Keep current location image for context - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_item_actions_keyboard(item_db_id, item_def, item.get('is_equipped', False), item['quantity']), image_path=location_image) - elif action_type == "inventory_use": - item_db_id = int(data[1]) - item = await database.get_inventory_item(item_db_id) - if not item: - await query.answer("Item not found.", show_alert=False) - return - - item_def = ITEMS.get(item['item_id'], {}) - - # Check if item is consumable - if item_def.get('type') != 'consumable': - await query.answer("This item cannot be used.", show_alert=False) - return - - # Answer callback before processing - await query.answer() - - # Apply item effects - result_parts = [] - updates = {} - - # Check for hp_restore - if 'hp_restore' in item_def: - hp_gain = item_def['hp_restore'] - new_hp = min(player['max_hp'], player['hp'] + hp_gain) - actual_gain = new_hp - player['hp'] - updates['hp'] = new_hp - if actual_gain > 0: - result_parts.append(f"โค๏ธ HP: +{actual_gain}") - else: - result_parts.append(f"โค๏ธ HP: Already at maximum!") - - # Check for stamina_restore - if 'stamina_restore' in item_def: - stamina_gain = item_def['stamina_restore'] - new_stamina = min(player['max_stamina'], player['stamina'] + stamina_gain) - actual_gain = new_stamina - player['stamina'] - updates['stamina'] = new_stamina - if actual_gain > 0: - result_parts.append(f"โšก๏ธ Stamina: +{actual_gain}") - else: - result_parts.append(f"โšก๏ธ Stamina: Already at maximum!") - - # Apply all updates at once - if updates: - await database.update_player(user_id, updates) - - # Remove one item from inventory - if item['quantity'] > 1: - await database.update_inventory_item(item['id'], quantity=item['quantity'] - 1) - else: - await database.remove_item_from_inventory(item['id']) - - # Build result message - emoji = item_def.get('emoji', 'โ”') - result_text = f"Used {emoji} {item_def.get('name')}\n\n" - if result_parts: - result_text += "\n".join(result_parts) - else: - result_text += "No effect." - - # Show updated inventory - inventory_items = await database.get_inventory(user_id) - - # Calculate inventory summary - current_weight, current_volume = logic.calculate_inventory_load(inventory_items) - max_weight, max_volume = logic.get_player_capacity(inventory_items, player) - - text = "๐ŸŽ’ Your Inventory:\n" - text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" - text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n\n" - - if not inventory_items: - text += "It's empty." - else: - text += f"{result_text}" - - # Keep current location image for context - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_keyboard(inventory_items), image_path=location_image) - - elif action_type == "inventory_drop": - item_db_id = int(data[1]) - drop_amount_str = data[2] if len(data) > 2 else None - - item = await database.get_inventory_item(item_db_id) - if not item: - await query.answer("Item not found.", show_alert=False) - return - - item_def = ITEMS.get(item['item_id'], {}) - - # Determine how much to drop - if drop_amount_str is None or drop_amount_str == "all": - # Drop all - pass the full quantity to remove_item_from_inventory - await database.drop_item_to_world(item['item_id'], item['quantity'], player['location_id']) - await database.remove_item_from_inventory(item['id'], quantity=item['quantity']) - await query.answer(f"You dropped all {item['quantity']}x {item_def.get('name')}.", show_alert=False) - else: - # Drop partial amount - drop_amount = int(drop_amount_str) - if drop_amount >= item['quantity']: - # Drop all - await database.drop_item_to_world(item['item_id'], item['quantity'], player['location_id']) - await database.remove_item_from_inventory(item['id'], quantity=item['quantity']) - await query.answer(f"You dropped all {item['quantity']}x {item_def.get('name')}.", show_alert=False) - else: - # Drop partial amount - await database.drop_item_to_world(item['item_id'], drop_amount, player['location_id']) - await database.update_inventory_item(item['id'], quantity=item['quantity'] - drop_amount) - await query.answer(f"You dropped {drop_amount}x {item_def.get('name')}.", show_alert=False) - - inventory_items = await database.get_inventory(user_id) - - # Calculate inventory summary - current_weight, current_volume = logic.calculate_inventory_load(inventory_items) - max_weight, max_volume = logic.get_player_capacity(inventory_items, player) - - text = "๐ŸŽ’ Your Inventory:\n" - text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" - text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n\n" - - if not inventory_items: - text += "It's empty." - - # Keep current location image for context - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_keyboard(inventory_items), image_path=location_image) - - elif action_type == "inventory_equip": - item_db_id = int(data[1]) - item = await database.get_inventory_item(item_db_id) - - if not item: - await query.answer("Item not found.", show_alert=False) - return - - item_def = ITEMS.get(item['item_id'], {}) - item_slot = item_def.get('slot') - - if not item_slot: - await query.answer("This item cannot be equipped.", show_alert=False) - return - - # Unequip any item in the same slot - inventory_items = await database.get_inventory(user_id) - for inv_item in inventory_items: - if inv_item.get('is_equipped'): - inv_item_def = ITEMS.get(inv_item['item_id'], {}) - if inv_item_def.get('slot') == item_slot: - await database.update_inventory_item(inv_item['id'], is_equipped=False) - - # If equipping from a stack (quantity > 1), split the stack - if item['quantity'] > 1: - # Reduce the stack by 1 - await database.update_inventory_item(item_db_id, quantity=item['quantity'] - 1) - # Create a new inventory entry with quantity 1 and equipped=True - new_item_id = await database.add_equipped_item_to_inventory(user_id, item['item_id']) - - await query.answer(f"Equipped {item_def.get('name')}!", show_alert=False) - - # Refresh the item view with the NEW equipped item - item = await database.get_inventory_item(new_item_id) - emoji = item_def.get('emoji', 'โ”') - text = f"Item: {emoji} {item_def.get('name', 'Unknown')}\n" - text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" - - if item_def.get('type') == 'weapon': - text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" - - text += "\nโœ… Currently Equipped" - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_item_actions_keyboard(new_item_id, item_def, True, item['quantity']), image_path=location_image) - else: - # Equip the single item - await database.update_inventory_item(item_db_id, is_equipped=True) - await query.answer(f"Equipped {item_def.get('name')}!", show_alert=False) - - # Refresh the item view - item = await database.get_inventory_item(item_db_id) - emoji = item_def.get('emoji', 'โ”') - text = f"Item: {emoji} {item_def.get('name', 'Unknown')}\n" - text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" - - if item_def.get('type') == 'weapon': - text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" - - text += "\nโœ… Currently Equipped" - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_item_actions_keyboard(item_db_id, item_def, True, item['quantity']), image_path=location_image) - - elif action_type == "inventory_unequip": - item_db_id = int(data[1]) - item = await database.get_inventory_item(item_db_id) - - if not item: - await query.answer("Item not found.", show_alert=False) - return - - item_def = ITEMS.get(item['item_id'], {}) - - # Check if there's an existing unequipped stack of the same item - inventory_items = await database.get_inventory(user_id) - existing_stack = None - for inv_item in inventory_items: - if inv_item['item_id'] == item['item_id'] and not inv_item.get('is_equipped') and inv_item['id'] != item_db_id: - existing_stack = inv_item - break - - if existing_stack: - # Merge into existing stack - await database.update_inventory_item(existing_stack['id'], quantity=existing_stack['quantity'] + 1) - # Remove the equipped item - await database.remove_item_from_inventory(item_db_id) - await query.answer(f"Unequipped {item_def.get('name')}.", show_alert=False) - - # Show the merged stack - item = await database.get_inventory_item(existing_stack['id']) - emoji = item_def.get('emoji', 'โ”') - text = f"Item: {emoji} {item_def.get('name', 'Unknown')}\n" - text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" - - if item_def.get('type') == 'weapon': - text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_item_actions_keyboard(existing_stack['id'], item_def, False, item['quantity']), image_path=location_image) - else: - # Just unequip the item - await database.update_inventory_item(item_db_id, is_equipped=False) - await query.answer(f"Unequipped {item_def.get('name')}.", show_alert=False) - - # Refresh the item view - item = await database.get_inventory_item(item_db_id) - emoji = item_def.get('emoji', 'โ”') - text = f"Item: {emoji} {item_def.get('name', 'Unknown')}\n" - text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" - - if item_def.get('type') == 'weapon': - text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" - - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text=text, reply_markup=keyboards.inventory_item_actions_keyboard(item_db_id, item_def, False, item['quantity']), image_path=location_image) - - # --- Combat Actions --- - elif action_type == "combat_attack": - from bot import combat - await query.answer() - - message, npc_died, turn_ended = await combat.player_attack(user_id) - - if npc_died: - # Combat ended - return to main menu - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text=message, reply_markup=keyboards.main_menu_keyboard(), image_path=location_image) - elif turn_ended: - # NPC's turn - auto-attack - npc_message, player_died = await combat.npc_attack(user_id) - message += "\n\n" + npc_message - - if player_died: - # Player died - show death message - await send_or_edit_with_image(query, text=message, reply_markup=None) - else: - # Show combat state - combat_data = await database.get_combat(user_id) - if combat_data: - from data.npcs import NPCS - npc_def = NPCS.get(combat_data['npc_id']) - keyboard = await keyboards.combat_keyboard(user_id) - await send_or_edit_with_image(query, text=message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) - else: - await query.answer(message, show_alert=False) - - elif action_type == "combat_flee": - from bot import combat - await query.answer() - - message, fled, turn_ended = await combat.flee_attempt(user_id) - - if fled: - # Successfully fled - return to main menu - location = game_world.get_location(player['location_id']) - location_image = location.image_path if location else None - await send_or_edit_with_image(query, text=message, reply_markup=keyboards.main_menu_keyboard(), image_path=location_image) - elif turn_ended: - # Failed to flee - NPC attacks - npc_message, player_died = await combat.npc_attack(user_id) - message += "\n\n" + npc_message - - if player_died: - await send_or_edit_with_image(query, text=message, reply_markup=None) - else: - combat_data = await database.get_combat(user_id) - if combat_data: - from data.npcs import NPCS - npc_def = NPCS.get(combat_data['npc_id']) - keyboard = await keyboards.combat_keyboard(user_id) - await send_or_edit_with_image(query, text=message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) - else: - await query.answer(message, show_alert=False) - - elif action_type == "combat_use_item_menu": - await query.answer() - keyboard = await keyboards.combat_items_keyboard(user_id) - await send_or_edit_with_image(query, text="๐Ÿ’Š Select an item to use:", reply_markup=keyboard) - - elif action_type == "combat_use_item": - from bot import combat - item_db_id = int(data[1]) - - message, turn_ended = await combat.use_item_in_combat(user_id, item_db_id) - await query.answer(message, show_alert=False) - - if turn_ended: - # NPC's turn - npc_message, player_died = await combat.npc_attack(user_id) - - if player_died: - await send_or_edit_with_image(query, text=message + "\n\n" + npc_message, reply_markup=None) - else: - combat_data = await database.get_combat(user_id) - if combat_data: - from data.npcs import NPCS - npc_def = NPCS.get(combat_data['npc_id']) - keyboard = await keyboards.combat_keyboard(user_id) - full_message = message + "\n\n" + npc_message + "\n\n๐ŸŽฏ Your turn!" - await send_or_edit_with_image(query, text=full_message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) - - elif action_type == "combat_back": - await query.answer() - combat_data = await database.get_combat(user_id) - if combat_data: - from data.npcs import NPCS - npc_def = NPCS.get(combat_data['npc_id']) - keyboard = await keyboards.combat_keyboard(user_id) - message = f"โš”๏ธ Combat with {npc_def.emoji} {npc_def.name}!\n" - message += f"{npc_def.emoji} Enemy HP: {combat_data['npc_hp']}/{combat_data['npc_max_hp']}\n" - message += f"โค๏ธ Your HP: {player['hp']}/{player['max_hp']}\n\n" - message += "๐ŸŽฏ Your turn!" if combat_data['turn'] == 'player' else "โณ Enemy's turn..." - await send_or_edit_with_image(query, text=message, reply_markup=keyboard, image_path=npc_def.image_url if npc_def else None) - - # --- Corpse Looting --- - elif action_type == "loot_player_corpse": - corpse_id = int(data[1]) - corpse = await database.get_player_corpse(corpse_id) - - if not corpse: - await query.answer("Corpse not found.", show_alert=False) - return - - items = json.loads(corpse['items']) - keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items) - - # Get location image - location = game_world.get_location(player['location_id']) - image_path = location.image_path if location else None - - await query.answer() - text = f"๐ŸŽ’ {corpse['player_name']}'s bag\n\nYou find the remains of another survivor..." - await send_or_edit_with_image(query, text=text, reply_markup=keyboard, image_path=image_path) - - elif action_type == "take_corpse_item": - corpse_id = int(data[1]) - item_index = int(data[2]) - - corpse = await database.get_player_corpse(corpse_id) - if not corpse: - await query.answer("Corpse not found.", show_alert=False) - return - - items = json.loads(corpse['items']) - if item_index >= len(items): - await query.answer("Item not found.", show_alert=False) - return - - item_data = items[item_index] - item_def = ITEMS.get(item_data['item_id'], {}) - - # Check inventory capacity - can_add, reason = await logic.can_add_item_to_inventory(user_id, item_data['item_id'], item_data['quantity']) - - if not can_add: - await query.answer(reason, show_alert=False) - return - - # Add to inventory - await database.add_item_to_inventory(user_id, item_data['item_id'], item_data['quantity']) - - # Remove from corpse - items.pop(item_index) - - if items: - await database.update_player_corpse(corpse_id, json.dumps(items)) - keyboard = keyboards.player_corpse_loot_keyboard(corpse_id, items) - - # Get location image - location = game_world.get_location(player['location_id']) - image_path = location.image_path if location else None - - await query.answer(f"Took {item_def.get('name', 'Unknown')}.", show_alert=False) - text = f"๐ŸŽ’ {corpse['player_name']}'s bag" - await send_or_edit_with_image(query, text=text, reply_markup=keyboard, image_path=image_path) - else: - # Bag is empty, remove it - await database.remove_player_corpse(corpse_id) - await query.answer(f"Took {item_def.get('name', 'Unknown')}. The bag is now empty.", show_alert=False) - location = game_world.get_location(player['location_id']) - dropped_items = await database.get_dropped_items_in_location(player['location_id']) - keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items) - await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=location.image_path if location else None) - - elif action_type == "scavenge_npc_corpse": - corpse_id = int(data[1]) - corpse = await database.get_npc_corpse(corpse_id) - - if not corpse: - await query.answer("Corpse not found.", show_alert=False) - return - - from data.npcs import NPCS - npc_def = NPCS.get(corpse['npc_id']) - loot_items = json.loads(corpse['loot_remaining']) - keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items) - - # Get location image - location = game_world.get_location(player['location_id']) - image_path = location.image_path if location else None - - await query.answer() - text = f"๐Ÿ”ช {npc_def.emoji} {npc_def.name} Corpse\n\n{npc_def.description}" - await send_or_edit_with_image(query, text=text, reply_markup=keyboard, image_path=image_path) - - elif action_type == "scavenge_corpse_item": - corpse_id = int(data[1]) - loot_index = int(data[2]) - - corpse = await database.get_npc_corpse(corpse_id) - if not corpse: - await query.answer("Corpse not found.", show_alert=False) - return - - loot_items = json.loads(corpse['loot_remaining']) - if loot_index >= len(loot_items): - await query.answer("Nothing to scavenge here.", show_alert=False) - return - - loot_data = loot_items[loot_index] - required_tool = loot_data.get('required_tool') - - # Check if player has required tool - if required_tool: - inventory_items = await database.get_inventory(user_id) - has_tool = any(item['item_id'] == required_tool for item in inventory_items) - - if not has_tool: - tool_def = ITEMS.get(required_tool, {}) - await query.answer(f"You need a {tool_def.get('name', 'tool')} to scavenge this.", show_alert=False) - return - - # Determine quantity - quantity = random.randint(loot_data['quantity_min'], loot_data['quantity_max']) - item_def = ITEMS.get(loot_data['item_id'], {}) - - # Check inventory capacity - can_add, reason = await logic.can_add_item_to_inventory(user_id, loot_data['item_id'], quantity) - - if not can_add: - await query.answer(reason, show_alert=False) - return - - # Add to inventory - await database.add_item_to_inventory(user_id, loot_data['item_id'], quantity) - - # Remove from corpse - loot_items.pop(loot_index) - - if loot_items: - await database.update_npc_corpse(corpse_id, json.dumps(loot_items)) - keyboard = keyboards.npc_corpse_scavenge_keyboard(corpse_id, loot_items) - - # Get location image - location = game_world.get_location(player['location_id']) - image_path = location.image_path if location else None - - await query.answer(f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}.", show_alert=False) - from data.npcs import NPCS - npc_def = NPCS.get(corpse['npc_id']) - text = f"๐Ÿ”ช {npc_def.emoji} {npc_def.name} Corpse" - await send_or_edit_with_image(query, text=text, reply_markup=keyboard, image_path=image_path) - else: - # Nothing left, remove corpse - await database.remove_npc_corpse(corpse_id) - await query.answer(f"Scavenged {quantity}x {item_def.get('name', 'Unknown')}. Nothing left on the corpse.", show_alert=False) - location = game_world.get_location(player['location_id']) - dropped_items = await database.get_dropped_items_in_location(player['location_id']) - keyboard = await keyboards.inspect_keyboard(player['location_id'], dropped_items) - await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=location.image_path if location else None) - - elif action_type == "no_op": - await query.answer() - return - elif action_type == "inspect_area_menu": - await query.answer() - location_id = data[1] - location = game_world.get_location(location_id) - dropped_items = await database.get_dropped_items_in_location(location_id) - keyboard = await keyboards.inspect_keyboard(location_id, dropped_items) - image_path = location.image_path if location else None - await send_or_edit_with_image(query, text="You scan the area. You notice...", reply_markup=keyboard, image_path=image_path) + except Exception as e: + logger.error(f"Error handling button action {action_type}: {e}", exc_info=True) + await query.answer("An error occurred. Please try again.", show_alert=True) diff --git a/bot/inventory_handlers.py b/bot/inventory_handlers.py new file mode 100644 index 0000000..51a6624 --- /dev/null +++ b/bot/inventory_handlers.py @@ -0,0 +1,355 @@ +""" +Inventory-related action handlers. +""" +import logging +from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from . import database, keyboards, logic +from data.world_loader import game_world +from data.items import ITEMS + +logger = logging.getLogger(__name__) + + +async def handle_inventory_menu(query, user_id: int, player: dict): + """Show player inventory.""" + await query.answer() + inventory_items = await database.get_inventory(user_id) + + # Calculate inventory summary + current_weight, current_volume = logic.calculate_inventory_load(inventory_items) + max_weight, max_volume = logic.get_player_capacity(inventory_items, player) + + text = "๐ŸŽ’ Your Inventory:\n" + text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" + text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n\n" + + if not inventory_items: + text += "It's empty." + + # Keep current location image for context + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=text, + reply_markup=keyboards.inventory_keyboard(inventory_items), + image_path=location_image + ) + + +async def handle_inventory_item(query, user_id: int, player: dict, data: list): + """Show details for a specific inventory item.""" + await query.answer() + item_db_id = int(data[1]) + item = await database.get_inventory_item(item_db_id) + item_def = ITEMS.get(item['item_id'], {}) + emoji = item_def.get('emoji', 'โ”') + + # Build item details text + text = f"{emoji} {item_def.get('name', 'Unknown')}\n" + + description = item_def.get('description') + if description: + text += f"{description}\n\n" + else: + text += "\n" + + text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" + + # Add weapon stats if applicable + if item_def.get('type') == 'weapon': + text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" + + # Add consumable effects if applicable + if item_def.get('type') == 'consumable': + effects = [] + if item_def.get('hp_restore'): + effects.append(f"โค๏ธ +{item_def.get('hp_restore')} HP") + if item_def.get('stamina_restore'): + effects.append(f"โšก +{item_def.get('stamina_restore')} Stamina") + if effects: + text += f"Effects: {', '.join(effects)}\n" + + # Add equipped status + if item.get('is_equipped'): + text += "\nโœ… Currently Equipped" + + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=text, + reply_markup=keyboards.inventory_item_actions_keyboard( + item_db_id, item_def, item.get('is_equipped', False), item['quantity'] + ), + image_path=location_image + ) + + +async def handle_inventory_use(query, user_id: int, player: dict, data: list): + """Use a consumable item from inventory.""" + item_db_id = int(data[1]) + item = await database.get_inventory_item(item_db_id) + + if not item: + await query.answer("Item not found.", show_alert=False) + return + + item_def = ITEMS.get(item['item_id'], {}) + + if item_def.get('type') != 'consumable': + await query.answer("This item cannot be used.", show_alert=False) + return + + await query.answer() + + # Apply item effects + result_parts = [] + updates = {} + + if 'hp_restore' in item_def: + hp_gain = item_def['hp_restore'] + new_hp = min(player['max_hp'], player['hp'] + hp_gain) + actual_gain = new_hp - player['hp'] + updates['hp'] = new_hp + if actual_gain > 0: + result_parts.append(f"โค๏ธ HP: +{actual_gain}") + else: + result_parts.append(f"โค๏ธ HP: Already at maximum!") + + if 'stamina_restore' in item_def: + stamina_gain = item_def['stamina_restore'] + new_stamina = min(player['max_stamina'], player['stamina'] + stamina_gain) + actual_gain = new_stamina - player['stamina'] + updates['stamina'] = new_stamina + if actual_gain > 0: + result_parts.append(f"โšก๏ธ Stamina: +{actual_gain}") + else: + result_parts.append(f"โšก๏ธ Stamina: Already at maximum!") + + if updates: + await database.update_player(user_id, updates) + + # Remove one item from inventory + if item['quantity'] > 1: + await database.update_inventory_item(item['id'], quantity=item['quantity'] - 1) + else: + await database.remove_item_from_inventory(item['id']) + + # Build result message + emoji = item_def.get('emoji', 'โ”') + result_text = f"Used {emoji} {item_def.get('name')}\n\n" + if result_parts: + result_text += "\n".join(result_parts) + else: + result_text += "No effect." + + # Show updated inventory + inventory_items = await database.get_inventory(user_id) + current_weight, current_volume = logic.calculate_inventory_load(inventory_items) + max_weight, max_volume = logic.get_player_capacity(inventory_items, player) + + text = "๐ŸŽ’ Your Inventory:\n" + text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" + text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n\n" + + if not inventory_items: + text += "It's empty." + else: + text += f"{result_text}" + + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=text, + reply_markup=keyboards.inventory_keyboard(inventory_items), + image_path=location_image + ) + + +async def handle_inventory_drop(query, user_id: int, player: dict, data: list): + """Drop an item from inventory to the world.""" + item_db_id = int(data[1]) + drop_amount_str = data[2] if len(data) > 2 else None + + item = await database.get_inventory_item(item_db_id) + if not item: + await query.answer("Item not found.", show_alert=False) + return + + item_def = ITEMS.get(item['item_id'], {}) + + # Determine how much to drop + if drop_amount_str is None or drop_amount_str == "all": + await database.drop_item_to_world(item['item_id'], item['quantity'], player['location_id']) + await database.remove_item_from_inventory(item['id'], quantity=item['quantity']) + await query.answer(f"You dropped all {item['quantity']}x {item_def.get('name')}.", show_alert=False) + else: + drop_amount = int(drop_amount_str) + if drop_amount >= item['quantity']: + await database.drop_item_to_world(item['item_id'], item['quantity'], player['location_id']) + await database.remove_item_from_inventory(item['id'], quantity=item['quantity']) + await query.answer(f"You dropped all {item['quantity']}x {item_def.get('name')}.", show_alert=False) + else: + await database.drop_item_to_world(item['item_id'], drop_amount, player['location_id']) + await database.update_inventory_item(item['id'], quantity=item['quantity'] - drop_amount) + await query.answer(f"You dropped {drop_amount}x {item_def.get('name')}.", show_alert=False) + + inventory_items = await database.get_inventory(user_id) + current_weight, current_volume = logic.calculate_inventory_load(inventory_items) + max_weight, max_volume = logic.get_player_capacity(inventory_items, player) + + text = "๐ŸŽ’ Your Inventory:\n" + text += f"๐Ÿ“Š Weight: {current_weight}/{max_weight} kg\n" + text += f"๐Ÿ“ฆ Volume: {current_volume}/{max_volume} vol\n\n" + + if not inventory_items: + text += "It's empty." + + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=text, + reply_markup=keyboards.inventory_keyboard(inventory_items), + image_path=location_image + ) + + +async def handle_inventory_equip(query, user_id: int, player: dict, data: list): + """Equip an item from inventory.""" + item_db_id = int(data[1]) + item = await database.get_inventory_item(item_db_id) + + if not item: + await query.answer("Item not found.", show_alert=False) + return + + item_def = ITEMS.get(item['item_id'], {}) + item_slot = item_def.get('slot') + + if not item_slot: + await query.answer("This item cannot be equipped.", show_alert=False) + return + + # Unequip any item in the same slot + inventory_items = await database.get_inventory(user_id) + for inv_item in inventory_items: + if inv_item.get('is_equipped'): + inv_item_def = ITEMS.get(inv_item['item_id'], {}) + if inv_item_def.get('slot') == item_slot: + await database.update_inventory_item(inv_item['id'], is_equipped=False) + + # If equipping from a stack, split the stack + if item['quantity'] > 1: + await database.update_inventory_item(item_db_id, quantity=item['quantity'] - 1) + new_item_id = await database.add_equipped_item_to_inventory(user_id, item['item_id']) + await query.answer(f"Equipped {item_def.get('name')}!", show_alert=False) + item = await database.get_inventory_item(new_item_id) + item_db_id = new_item_id + else: + await database.update_inventory_item(item_db_id, is_equipped=True) + await query.answer(f"Equipped {item_def.get('name')}!", show_alert=False) + item = await database.get_inventory_item(item_db_id) + + # Refresh the item view + emoji = item_def.get('emoji', 'โ”') + text = f"{emoji} {item_def.get('name', 'Unknown')}\n" + + description = item_def.get('description') + if description: + text += f"{description}\n\n" + else: + text += "\n" + + text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" + + if item_def.get('type') == 'weapon': + text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" + + text += "\nโœ… Currently Equipped" + + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=text, + reply_markup=keyboards.inventory_item_actions_keyboard( + item_db_id, item_def, True, item['quantity'] + ), + image_path=location_image + ) + + +async def handle_inventory_unequip(query, user_id: int, player: dict, data: list): + """Unequip an item.""" + item_db_id = int(data[1]) + item = await database.get_inventory_item(item_db_id) + + if not item: + await query.answer("Item not found.", show_alert=False) + return + + item_def = ITEMS.get(item['item_id'], {}) + + # Check if there's an existing unequipped stack + inventory_items = await database.get_inventory(user_id) + existing_stack = None + for inv_item in inventory_items: + if (inv_item['item_id'] == item['item_id'] and + not inv_item.get('is_equipped') and + inv_item['id'] != item_db_id): + existing_stack = inv_item + break + + if existing_stack: + # Merge into existing stack + await database.update_inventory_item(existing_stack['id'], quantity=existing_stack['quantity'] + 1) + await database.remove_item_from_inventory(item_db_id) + await query.answer(f"Unequipped {item_def.get('name')}.", show_alert=False) + item = await database.get_inventory_item(existing_stack['id']) + item_db_id = existing_stack['id'] + else: + # Just unequip + await database.update_inventory_item(item_db_id, is_equipped=False) + await query.answer(f"Unequipped {item_def.get('name')}.", show_alert=False) + item = await database.get_inventory_item(item_db_id) + + # Refresh the item view + emoji = item_def.get('emoji', 'โ”') + text = f"{emoji} {item_def.get('name', 'Unknown')}\n" + + description = item_def.get('description') + if description: + text += f"{description}\n\n" + else: + text += "\n" + + text += f"Weight: {item_def.get('weight', 0)} kg | Volume: {item_def.get('volume', 0)} vol\n" + + if item_def.get('type') == 'weapon': + text += f"Damage: {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n" + + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=text, + reply_markup=keyboards.inventory_item_actions_keyboard( + item_db_id, item_def, False, item['quantity'] + ), + image_path=location_image + ) diff --git a/bot/pickup_handlers.py b/bot/pickup_handlers.py new file mode 100644 index 0000000..83dfa4c --- /dev/null +++ b/bot/pickup_handlers.py @@ -0,0 +1,135 @@ +""" +Pickup and item collection handlers. +""" +import logging +from . import database, keyboards, logic +from data.world_loader import game_world +from data.items import ITEMS + +logger = logging.getLogger(__name__) + + +async def handle_pickup_menu(query, user_id: int, player: dict, data: list): + """Show pickup options for a dropped item.""" + dropped_item_id = int(data[1]) + item_to_pickup = await database.get_dropped_item(dropped_item_id) + + if not item_to_pickup: + await query.answer("Someone already picked that up!", show_alert=False) + location_id = player['location_id'] + location = game_world.get_location(location_id) + dropped_items = await database.get_dropped_items_in_location(location_id) + wandering_enemies = await database.get_wandering_enemies_in_location(location_id) + keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) + image_path = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text="You scan the area. You notice...", + reply_markup=keyboard, + image_path=image_path + ) + return + + item_def = ITEMS.get(item_to_pickup['item_id'], {}) + emoji = item_def.get('emoji', 'โ”') + text = f"{emoji} {item_def.get('name', 'Unknown')}\n\n" + text += f"Available: {item_to_pickup['quantity']}\n" + text += f"Weight: {item_def.get('weight', 0)} kg each\n" + text += f"Volume: {item_def.get('volume', 0)} vol each\n\n" + text += "How many do you want to pick up?" + + await query.answer() + keyboard = keyboards.pickup_options_keyboard( + dropped_item_id, + item_def.get('name', 'Unknown'), + item_to_pickup['quantity'] + ) + + location = game_world.get_location(player['location_id']) + image_path = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=text, + reply_markup=keyboard, + image_path=image_path + ) + + +async def handle_pickup(query, user_id: int, player: dict, data: list): + """Pick up a dropped item from the world.""" + dropped_item_id = int(data[1]) + pickup_amount_str = data[2] if len(data) > 2 else "all" + + item_to_pickup = await database.get_dropped_item(dropped_item_id) + if not item_to_pickup: + await query.answer("Someone already picked that up!", show_alert=False) + location_id = player['location_id'] + location = game_world.get_location(location_id) + dropped_items = await database.get_dropped_items_in_location(location_id) + wandering_enemies = await database.get_wandering_enemies_in_location(location_id) + keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) + image_path = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text="You scan the area. You notice...", + reply_markup=keyboard, + image_path=image_path + ) + return + + # Determine how much to pick up + if pickup_amount_str == "all": + pickup_amount = item_to_pickup['quantity'] + else: + pickup_amount = min(int(pickup_amount_str), item_to_pickup['quantity']) + + # Check inventory capacity + can_add, reason = await logic.can_add_item_to_inventory( + user_id, item_to_pickup['item_id'], pickup_amount + ) + + if not can_add: + await query.answer(reason, show_alert=True) + return + + # Add to inventory + await database.add_item_to_inventory(user_id, item_to_pickup['item_id'], pickup_amount) + + # Update or remove dropped item + remaining = item_to_pickup['quantity'] - pickup_amount + item_def = ITEMS.get(item_to_pickup['item_id'], {}) + + if remaining > 0: + await database.update_dropped_item(dropped_item_id, remaining) + await query.answer( + f"Picked up {pickup_amount}x {item_def.get('name', 'item')}. {remaining} remaining.", + show_alert=False + ) + else: + await database.remove_dropped_item(dropped_item_id) + await query.answer( + f"Picked up {pickup_amount}x {item_def.get('name', 'item')}.", + show_alert=False + ) + + # Return to inspect area + location_id = player['location_id'] + location = game_world.get_location(location_id) + dropped_items = await database.get_dropped_items_in_location(location_id) + wandering_enemies = await database.get_wandering_enemies_in_location(location_id) + keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies) + image_path = location.image_path if location else None + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text="You scan the area. You notice...", + reply_markup=keyboard, + image_path=image_path + ) diff --git a/bot/profile_handlers.py b/bot/profile_handlers.py new file mode 100644 index 0000000..bdd7adf --- /dev/null +++ b/bot/profile_handlers.py @@ -0,0 +1,152 @@ +""" +Profile and character stat management handlers. +""" +import logging +from telegram import InlineKeyboardButton, InlineKeyboardMarkup +from . import database, keyboards +from data.world_loader import game_world + +logger = logging.getLogger(__name__) + + +async def handle_profile(query, user_id: int, player: dict): + """Show player profile and stats.""" + await query.answer() + from bot import combat + from .utils import format_stat_bar, create_progress_bar + + # Calculate stats + xp_current = player['xp'] + xp_needed = combat.xp_for_level(player['level'] + 1) + xp_for_current_level = combat.xp_for_level(player['level']) + xp_progress = max(0, xp_current - xp_for_current_level) + xp_level_requirement = xp_needed - xp_for_current_level + progress_percent = int((xp_progress / xp_level_requirement) * 100) if xp_level_requirement > 0 else 0 + + unspent = player.get('unspent_points', 0) + + # Build profile with visual bars + profile_text = f"๐Ÿ‘ค {player['name']}\n" + profile_text += f"โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n\n" + profile_text += f"Level: {player['level']}\n" + + # XP bar + xp_bar = create_progress_bar(xp_progress, xp_level_requirement, length=10) + profile_text += f"โญ XP: {xp_bar} {progress_percent}% ({xp_current}/{xp_needed})\n" + + if unspent > 0: + profile_text += f"๐Ÿ’Ž Unspent Points: {unspent}\n" + + profile_text += f"\n{format_stat_bar('HP', 'โค๏ธ', player['hp'], player['max_hp'])}\n" + profile_text += f"{format_stat_bar('Stamina', 'โšก', player['stamina'], player['max_stamina'])}\n\n" + profile_text += f"Stats:\n" + profile_text += f"๐Ÿ’ช Strength: {player['strength']}\n" + profile_text += f"๐Ÿƒ Agility: {player['agility']}\n" + profile_text += f"๐Ÿ’š Endurance: {player['endurance']}\n" + profile_text += f"๐Ÿง  Intellect: {player['intellect']}\n\n" + profile_text += f"Combat:\n" + profile_text += f"โš”๏ธ Base Damage: {5 + player['strength'] // 2 + player['level']}\n" + profile_text += f"๐Ÿ›ก๏ธ Flee Chance: {int((0.5 + player['agility'] / 100) * 100)}%\n" + profile_text += f"๐Ÿ’š Stamina Regen: {1 + player['endurance'] // 10}/cycle\n" + + location = game_world.get_location(player['location_id']) + location_image = location.image_path if location else None + + # Add spend points button if player has unspent points + keyboard_buttons = [] + if unspent > 0: + keyboard_buttons.append([ + InlineKeyboardButton("โญ Spend Stat Points", callback_data="spend_points_menu") + ]) + keyboard_buttons.append([InlineKeyboardButton("โฌ…๏ธ Back", callback_data="main_menu")]) + back_keyboard = InlineKeyboardMarkup(keyboard_buttons) + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text=profile_text, + reply_markup=back_keyboard, + image_path=location_image + ) + + +async def handle_spend_points_menu(query, user_id: int, player: dict): + """Show stat point spending menu.""" + await query.answer() + unspent = player.get('unspent_points', 0) + + if unspent <= 0: + await query.answer("You have no points to spend!", show_alert=False) + return + + text = f"โญ Spend Stat Points\n\n" + text += f"Available Points: {unspent}\n\n" + text += f"Current Stats:\n" + text += f"โค๏ธ Max HP: {player['max_hp']} (+10 per point)\n" + text += f"โšก Max Stamina: {player['max_stamina']} (+5 per point)\n" + text += f"๐Ÿ’ช Strength: {player['strength']} (+1 per point)\n" + text += f"๐Ÿƒ Agility: {player['agility']} (+1 per point)\n" + text += f"๐Ÿ’š Endurance: {player['endurance']} (+1 per point)\n" + text += f"๐Ÿง  Intellect: {player['intellect']} (+1 per point)\n\n" + text += f"๐Ÿ’ก Choose wisely! Each point matters." + + keyboard = keyboards.spend_points_keyboard() + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image(query, text=text, reply_markup=keyboard) + + +async def handle_spend_point(query, user_id: int, player: dict, data: list): + """Spend a stat point on a specific attribute.""" + stat_name = data[1] + unspent = player.get('unspent_points', 0) + + if unspent <= 0: + await query.answer("You have no points to spend!", show_alert=False) + return + + # Map stat names to updates + stat_mapping = { + 'max_hp': ('max_hp', 10, 'โค๏ธ Max HP'), + 'max_stamina': ('max_stamina', 5, 'โšก Max Stamina'), + 'strength': ('strength', 1, '๐Ÿ’ช Strength'), + 'agility': ('agility', 1, '๐Ÿƒ Agility'), + 'endurance': ('endurance', 1, '๐Ÿ’š Endurance'), + 'intellect': ('intellect', 1, '๐Ÿง  Intellect'), + } + + if stat_name not in stat_mapping: + await query.answer("Invalid stat!", show_alert=False) + return + + db_field, increase, display_name = stat_mapping[stat_name] + new_value = player[db_field] + increase + new_unspent = unspent - 1 + + await database.update_player(user_id, { + db_field: new_value, + 'unspent_points': new_unspent + }) + + # Update local player data + player[db_field] = new_value + player['unspent_points'] = new_unspent + + await query.answer(f"+{increase} {display_name}!", show_alert=False) + + # Refresh the spend points menu + text = f"โญ Spend Stat Points\n\n" + text += f"Available Points: {new_unspent}\n\n" + text += f"Current Stats:\n" + text += f"โค๏ธ Max HP: {player['max_hp']} (+10 per point)\n" + text += f"โšก Max Stamina: {player['max_stamina']} (+5 per point)\n" + text += f"๐Ÿ’ช Strength: {player['strength']} (+1 per point)\n" + text += f"๐Ÿƒ Agility: {player['agility']} (+1 per point)\n" + text += f"๐Ÿ’š Endurance: {player['endurance']} (+1 per point)\n" + text += f"๐Ÿง  Intellect: {player['intellect']} (+1 per point)\n\n" + text += f"๐Ÿ’ก Choose wisely! Each point matters." + + keyboard = keyboards.spend_points_keyboard() + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image(query, text=text, reply_markup=keyboard) diff --git a/bot/utils.py b/bot/utils.py index 295d476..1f10fa9 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -10,6 +10,64 @@ from telegram.ext import ContextTypes logger = logging.getLogger(__name__) +def create_progress_bar(current: int, maximum: int, length: int = 10, filled_char: str = "โ–ˆ", empty_char: str = "โ–‘") -> str: + """ + Create a visual progress bar. + + Args: + current: Current value + maximum: Maximum value + length: Length of the bar in characters (default 10) + filled_char: Character for filled portion (default โ–ˆ) + empty_char: Character for empty portion (default โ–‘) + + Returns: + String representation of progress bar + + Examples: + >>> create_progress_bar(75, 100) + "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘" + >>> create_progress_bar(0, 100) + "โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘" + >>> create_progress_bar(100, 100) + "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ" + """ + if maximum <= 0: + return empty_char * length + + percentage = min(1.0, max(0.0, current / maximum)) + filled_length = int(length * percentage) + empty_length = length - filled_length + + return filled_char * filled_length + empty_char * empty_length + + +def format_stat_bar(label: str, emoji: str, current: int, maximum: int, bar_length: int = 10) -> str: + """ + Format a stat (HP, Stamina, etc.) with visual progress bar. + + Args: + label: Stat label (e.g., "HP", "Stamina") + emoji: Emoji to display (e.g., "โค๏ธ", "โšก") + current: Current value + maximum: Maximum value + bar_length: Length of the progress bar + + Returns: + Formatted string with bar and percentage + + Examples: + >>> format_stat_bar("HP", "โค๏ธ", 75, 100) + "โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 75% (75/100)" + >>> format_stat_bar("Stamina", "โšก", 50, 100) + "โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100)" + """ + bar = create_progress_bar(current, maximum, bar_length) + percentage = int((current / maximum * 100)) if maximum > 0 else 0 + + return f"{emoji} {label}: {bar} {percentage}% ({current}/{maximum})" + + def get_admin_ids(): """Get the list of admin user IDs from environment variable.""" admin_ids_str = os.getenv("ADMIN_IDS", "") diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..db04d8a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,140 @@ +# Echoes of the Ashes - Documentation Index + +## ๐Ÿ“š Documentation Overview + +This directory contains all project documentation organized by category. + +## ๐Ÿ“ Directory Structure + +### `/docs/development/` +Technical documentation for developers + +- **[BOT_MODULE.md](development/BOT_MODULE.md)** - Bot module architecture and handler system +- **[HANDLER_REFACTORING_SUMMARY.md](development/HANDLER_REFACTORING_SUMMARY.md)** - Code refactoring summary +- **[REFACTORING_NOTES.md](development/REFACTORING_NOTES.md)** - Detailed refactoring notes +- **[VISUAL_IMPROVEMENTS.md](development/VISUAL_IMPROVEMENTS.md)** - UI improvements and progress bars +- **[UI_EXAMPLES.md](development/UI_EXAMPLES.md)** - Before/after UI comparisons and visual mockups + +### `/docs/game/` +Game design and mechanics documentation + +- **[MECHANICS.md](game/MECHANICS.md)** - Complete game mechanics overview + +### `/docs/api/` +API documentation and integration guides + +- **Telegram Bot API** - Bot command reference +- **Database Schema** - Data models and relationships +- **Web Map Editor** - Map editor API and usage + +## ๐Ÿš€ Quick Links + +### For Developers +- [Bot Module Documentation](development/BOT_MODULE.md) - Start here to understand the codebase +- [Handler System](development/HANDLER_REFACTORING_SUMMARY.md) - Handler architecture +- [Contributing Guide](../README.md#contributing) - How to contribute + +### For Game Designers +- [Game Mechanics](game/) - Game systems and balance +- [World Editor](../web-map/README.md) - Map editing guide + +### For Players +- [README](../README.md) - Project overview and setup +- [Game Guide](game/) - How to play + +## ๐Ÿ“ Documentation Standards + +### Markdown Guidelines +- Use clear, descriptive headings +- Include code examples where relevant +- Keep lines under 100 characters for readability +- Use emoji sparingly for visual organization + +### File Naming +- Use `SCREAMING_SNAKE_CASE.md` for major documentation +- Use `PascalCase.md` for component-specific docs +- Use `kebab-case.md` for guides and tutorials + +### Document Structure +```markdown +# Title + +## Overview +Brief description + +## Table of Contents +- [Section 1](#section-1) +- [Section 2](#section-2) + +## Content +Detailed information + +## See Also +Related documentation +``` + +## ๐Ÿ”„ Recent Updates + +### October 19, 2025 +- โœ… Reorganized documentation into structured folders +- โœ… Created documentation index (this file) +- โœ… Moved development docs from root to docs/development/ +- โœ… **Added visual HP/Stamina bars** - Progress bars for better UI feedback +- โœ… Refactored handler system into modular architecture +- โœ… Created comprehensive bot module documentation +- โœ… Added utility functions for visual progress displays + +### October 18, 2025 +- โœ… Refactored handler system into modular architecture +- โœ… Created comprehensive bot module documentation +- โœ… Added refactoring notes and summaries + +## ๐Ÿ“– Contributing to Documentation + +When adding new documentation: + +1. **Choose the right folder:** + - `development/` - Technical/code documentation + - `game/` - Game design and mechanics + - `api/` - API references and integration + +2. **Update this index** with links to new documents + +3. **Follow naming conventions** as outlined above + +4. **Include:** + - Clear title and overview + - Table of contents for longer docs + - Code examples where applicable + - Links to related documentation + +5. **Keep it updated** - Documentation should match the code + +## ๐Ÿ” Finding Documentation + +### By Topic +- **Setup & Installation** โ†’ [README.md](../README.md) +- **Bot Development** โ†’ [development/BOT_MODULE.md](development/BOT_MODULE.md) +- **Code Architecture** โ†’ [development/](development/) +- **Game Mechanics** โ†’ [game/](game/) +- **Map Editor** โ†’ [../web-map/README.md](../web-map/README.md) +- **Database** โ†’ [api/](api/) + +### By File Type +- **README files** - Quick overviews and getting started guides +- **Technical specs** - Detailed architecture and implementation +- **Guides** - Step-by-step tutorials +- **Reference** - API documentation and data schemas + +## ๐Ÿ“ง Questions? + +If you can't find what you're looking for: +1. Check the [main README](../README.md) +2. Search through existing documentation +3. Look at code comments and docstrings +4. Create an issue for missing documentation + +--- + +**Last Updated:** October 19, 2025 +**Maintained by:** Development Team diff --git a/docs/development/BOT_MODULE.md b/docs/development/BOT_MODULE.md new file mode 100644 index 0000000..46f431f --- /dev/null +++ b/docs/development/BOT_MODULE.md @@ -0,0 +1,298 @@ +# Bot Module Documentation + +## Overview +The bot module contains all the Telegram bot logic for "Echoes of the Ashes" RPG game. + +## Module Structure + +### Core Modules +- **`handlers.py`** (14KB) - Main message router and utility functions +- **`database.py`** - Database operations and queries +- **`keyboards.py`** - Telegram keyboard layouts +- **`logic.py`** - Game logic and calculations +- **`combat.py`** (17KB) - Combat system implementation +- **`spawn_manager.py`** - Enemy spawning system +- **`utils.py`** - Utility functions and decorators + +### Handler Modules (Refactored) +Organized by functionality for better maintainability: + +#### **`action_handlers.py`** (14KB, 367 lines) +World interaction and inspection +- `get_player_status_text()` - Player status display +- `handle_inspect_area()` - Inspect locations +- `handle_attack_wandering()` - Attack wandering enemies +- `handle_inspect_interactable()` - Inspect objects +- `handle_action()` - Perform actions +- `handle_main_menu()` - Main menu navigation +- `handle_move_menu()` - Movement menu +- `handle_move()` - Travel between locations + +#### **`inventory_handlers.py`** (13KB, 355 lines) +Inventory management system +- `handle_inventory_menu()` - Show inventory +- `handle_inventory_item()` - Item details +- `handle_inventory_use()` - Use consumables +- `handle_inventory_drop()` - Drop items +- `handle_inventory_equip()` - Equip gear +- `handle_inventory_unequip()` - Unequip gear + +#### **`pickup_handlers.py`** (5.1KB, 135 lines) +Item collection from world +- `handle_pickup_menu()` - Pickup options +- `handle_pickup()` - Pick up items + +#### **`combat_handlers.py`** (6.3KB, 171 lines) +Combat action handlers +- `handle_combat_attack()` - Attack enemy +- `handle_combat_flee()` - Flee combat +- `handle_combat_use_item_menu()` - Combat items menu +- `handle_combat_use_item()` - Use item in combat +- `handle_combat_back()` - Return to combat menu + +#### **`profile_handlers.py`** (6.0KB, 147 lines) +Character progression +- `handle_profile()` - View profile +- `handle_spend_points_menu()` - Stat points menu +- `handle_spend_point()` - Allocate stat points + +#### **`corpse_handlers.py`** (8.2KB, 234 lines) +Looting system +- `handle_loot_player_corpse()` - Loot player corpses +- `handle_take_corpse_item()` - Take items from player corpse +- `handle_scavenge_npc_corpse()` - Scavenge NPC corpses +- `handle_scavenge_corpse_item()` - Take items from NPC corpse + +## Architecture + +### Message Flow +``` +Telegram Update + โ†“ +main.py (Application setup) + โ†“ +handlers.py::button_handler (Router) + โ†“ +Specialized Handler (action_handlers, combat_handlers, etc.) + โ†“ +database.py (Data operations) + โ†“ +keyboards.py (UI response) + โ†“ +Response to User +``` + +### Handler Pattern +All handlers follow a consistent pattern: + +```python +async def handle_action(query, user_id: int, player: dict, data: list = None): + """ + Handle specific action. + + Args: + query: Telegram callback query object + user_id: Telegram user ID + player: Player data dict + data: Optional list of parameters from callback_data + """ + # 1. Validate input + # 2. Perform action + # 3. Update database + # 4. Send response with send_or_edit_with_image() +``` + +### Shared Utilities + +#### `send_or_edit_with_image()` +Central function for sending/editing messages with images: +```python +await send_or_edit_with_image( + query, + text="Message text", + reply_markup=keyboard, + image_path="/path/to/image.png" # Optional +) +``` + +Features: +- Smooth image transitions +- Image caching for performance +- Automatic fallback to text-only +- Edit vs. send detection + +## Design Principles + +### 1. Separation of Concerns +Each module handles a specific domain: +- Handlers โ†’ User interaction +- Database โ†’ Data persistence +- Logic โ†’ Game rules +- Combat โ†’ Battle mechanics + +### 2. Single Responsibility +Each function has one clear purpose: +- โœ… `handle_inventory_use()` - Use items +- โœ… `handle_combat_attack()` - Attack in combat +- โŒ ~~One giant function that does everything~~ + +### 3. Consistency +All handlers: +- Accept same parameter pattern +- Use async/await +- Handle errors gracefully +- Log important actions + +### 4. Error Handling +Centralized in `button_handler`: +```python +try: + await handle_specific_action(...) +except Exception as e: + logger.error(f"Error: {e}", exc_info=True) + await query.answer("An error occurred.", show_alert=True) +``` + +## Adding New Handlers + +### Step 1: Create Handler Function +```python +# In appropriate *_handlers.py module +async def handle_new_action(query, user_id: int, player: dict, data: list): + """Handle new action.""" + await query.answer() + + # Your logic here + + from .handlers import send_or_edit_with_image + await send_or_edit_with_image( + query, + text="Action result", + reply_markup=some_keyboard() + ) +``` + +### Step 2: Import in handlers.py +```python +from .your_handlers import handle_new_action +``` + +### Step 3: Add Route +```python +# In button_handler() +elif action_type == "new_action": + await handle_new_action(query, user_id, player, data) +``` + +### Step 4: Create Keyboard Button +```python +# In keyboards.py +InlineKeyboardButton( + "New Action", + callback_data="new_action:param1:param2" +) +``` + +## Testing + +### Manual Testing +1. Start the bot: `python main.py` +2. Send `/start` to the bot +3. Test each button action +4. Check logs for errors + +### Unit Testing (Future) +```python +# tests/test_handlers.py +async def test_handle_inventory_use(): + result = await handle_inventory_use( + mock_query, + user_id=123, + player=mock_player, + data=['inventory_use', '1'] + ) + assert result is not None +``` + +## Performance Considerations + +### Image Caching +Images are cached after first upload: +```python +cached_file_id = await database.get_cached_image(image_path) +if not cached_file_id: + # Upload and cache + await database.cache_image(image_path, file_id) +``` + +### Database Queries +- Use indexed lookups (user_id, location_id) +- Batch operations where possible +- Async all the way + +### Memory +- Don't store large objects in memory +- Use database for persistence +- Clean up old data periodically + +## Debugging + +### Enable Debug Logging +```python +# In main.py +logging.basicConfig( + level=logging.DEBUG, # Change from INFO + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +``` + +### Common Issues + +**Issue:** Handler not being called +- Check callback_data format: `"action_type:param1:param2"` +- Verify route exists in `button_handler()` +- Check import statement + +**Issue:** Image not showing +- Verify image path exists +- Check image cache +- Look for upload errors in logs + +**Issue:** Database errors +- Check player exists +- Verify foreign key relationships +- Look at database schema + +## Migration Guide + +### From Old handlers.py +Old code in backup files: +- `backups/handlers_original.py` +- `backups/handlers.py.old` + +All functionality preserved, just reorganized into modules. + +## Future Enhancements + +1. **Type Hints** - Add proper type annotations +2. **Unit Tests** - Test coverage for all handlers +3. **Decorators** - Common validations (requires_combat, requires_stamina) +4. **Base Classes** - Handler base class with common functionality +5. **Event System** - Decouple actions from responses + +## Contributing + +When adding new features: +1. Choose the appropriate handler module (or create new one) +2. Follow the existing pattern +3. Add docstrings +4. Log important actions +5. Handle errors gracefully +6. Update this documentation + +## Questions? + +See also: +- `REFACTORING_NOTES.md` - Refactoring details +- `HANDLER_REFACTORING_SUMMARY.md` - Before/after comparison +- `README.md` - Project overview diff --git a/docs/development/HANDLER_REFACTORING_SUMMARY.md b/docs/development/HANDLER_REFACTORING_SUMMARY.md new file mode 100644 index 0000000..766fe92 --- /dev/null +++ b/docs/development/HANDLER_REFACTORING_SUMMARY.md @@ -0,0 +1,180 @@ +# Handler Refactoring Summary + +## Code Metrics + +### Before Refactoring +- **Single file:** `bot/handlers.py` (~1,308 lines) +- **Giant function:** `button_handler()` with 1000+ lines of if/elif chains +- **Maintainability:** Very difficult to navigate and modify + +### After Refactoring +- **7 organized modules:** Total ~1,786 lines (well-structured) +- **Main router:** `handlers.py` (377 lines) - clean routing logic +- **6 specialized modules:** + - `action_handlers.py` (367 lines) - World interaction + - `inventory_handlers.py` (355 lines) - Inventory management + - `corpse_handlers.py` (234 lines) - Corpse looting + - `combat_handlers.py` (171 lines) - Combat actions + - `profile_handlers.py` (147 lines) - Profile & stats + - `pickup_handlers.py` (135 lines) - Item pickup + +## Architecture + +``` +bot/ +โ”œโ”€โ”€ handlers.py # Main router & utilities +โ”‚ โ”œโ”€โ”€ send_or_edit_with_image() +โ”‚ โ”œโ”€โ”€ start() +โ”‚ โ”œโ”€โ”€ export_map() +โ”‚ โ”œโ”€โ”€ spawn_stats() +โ”‚ โ””โ”€โ”€ button_handler() # Routes to specialized handlers +โ”‚ +โ”œโ”€โ”€ action_handlers.py # World & Inspection +โ”‚ โ”œโ”€โ”€ get_player_status_text() +โ”‚ โ”œโ”€โ”€ handle_inspect_area() +โ”‚ โ”œโ”€โ”€ handle_attack_wandering() +โ”‚ โ”œโ”€โ”€ handle_inspect_interactable() +โ”‚ โ”œโ”€โ”€ handle_action() +โ”‚ โ”œโ”€โ”€ handle_main_menu() +โ”‚ โ”œโ”€โ”€ handle_move_menu() +โ”‚ โ””โ”€โ”€ handle_move() +โ”‚ +โ”œโ”€โ”€ inventory_handlers.py # Inventory Management +โ”‚ โ”œโ”€โ”€ handle_inventory_menu() +โ”‚ โ”œโ”€โ”€ handle_inventory_item() +โ”‚ โ”œโ”€โ”€ handle_inventory_use() +โ”‚ โ”œโ”€โ”€ handle_inventory_drop() +โ”‚ โ”œโ”€โ”€ handle_inventory_equip() +โ”‚ โ””โ”€โ”€ handle_inventory_unequip() +โ”‚ +โ”œโ”€โ”€ pickup_handlers.py # Item Collection +โ”‚ โ”œโ”€โ”€ handle_pickup_menu() +โ”‚ โ””โ”€โ”€ handle_pickup() +โ”‚ +โ”œโ”€โ”€ combat_handlers.py # Combat System +โ”‚ โ”œโ”€โ”€ handle_combat_attack() +โ”‚ โ”œโ”€โ”€ handle_combat_flee() +โ”‚ โ”œโ”€โ”€ handle_combat_use_item_menu() +โ”‚ โ”œโ”€โ”€ handle_combat_use_item() +โ”‚ โ””โ”€โ”€ handle_combat_back() +โ”‚ +โ”œโ”€โ”€ profile_handlers.py # Character Stats +โ”‚ โ”œโ”€โ”€ handle_profile() +โ”‚ โ”œโ”€โ”€ handle_spend_points_menu() +โ”‚ โ””โ”€โ”€ handle_spend_point() +โ”‚ +โ””โ”€โ”€ corpse_handlers.py # Looting System + โ”œโ”€โ”€ handle_loot_player_corpse() + โ”œโ”€โ”€ handle_take_corpse_item() + โ”œโ”€โ”€ handle_scavenge_npc_corpse() + โ””โ”€โ”€ handle_scavenge_corpse_item() +``` + +## Key Improvements + +### 1. Separation of Concerns +Each module handles a specific domain of functionality: +- **Action Handlers** โ†’ World exploration & interaction +- **Inventory Handlers** โ†’ Item management +- **Combat Handlers** โ†’ Battle mechanics +- **Profile Handlers** โ†’ Character progression +- **Corpse Handlers** โ†’ Looting system +- **Pickup Handlers** โ†’ Item collection + +### 2. Single Responsibility Principle +Each function has one clear purpose: +- โœ… `handle_inventory_use()` - Use items +- โœ… `handle_combat_attack()` - Attack in combat +- โœ… `handle_pickup()` - Pick up items +- โŒ ~~`button_handler()` - Do everything~~ + +### 3. Improved Error Handling +```python +# Centralized error handling in router +try: + if action_type == "inspect_area": + await handle_inspect_area(query, user_id, player) + # ... more routes +except Exception as e: + logger.error(f"Error handling {action_type}: {e}", exc_info=True) + await query.answer("An error occurred.", show_alert=True) +``` + +### 4. Better Code Navigation +- Jump to specific functionality in seconds +- IDE autocomplete works better +- Easier to review code changes +- Reduced cognitive load + +### 5. Testability +Each handler can now be tested independently: +```python +# Easy to test +await handle_inventory_use(mock_query, user_id, player, data) + +# vs. testing 1000 lines of if/elif +``` + +## Migration Path + +All functionality has been preserved. The refactoring only changed the organization, not the behavior. + +### Verified Compatible +- โœ… All action types still handled +- โœ… Same function signatures +- โœ… Same error handling behavior +- โœ… No breaking changes to external code + +### Backup Files +Original files saved in `backups/`: +- `handlers_original.py` +- `handlers.py.old` + +## Future Enhancements + +1. **Add Type Hints** + ```python + async def handle_inventory_use( + query: CallbackQuery, + user_id: int, + player: dict, + data: list[str] + ) -> None: + ``` + +2. **Create Base Handler Class** + ```python + class BaseHandler: + def __init__(self, query, user_id, player): + self.query = query + self.user_id = user_id + self.player = player + ``` + +3. **Add Unit Tests** + ```python + def test_handle_inventory_use(): + # Test consumable usage + # Test inventory updates + # Test error cases + ``` + +4. **Add Handler Decorators** + ```python + @requires_combat + async def handle_combat_attack(...): + + @requires_stamina(10) + async def handle_action(...): + ``` + +## Conclusion + +This refactoring transforms a monolithic 1,308-line file with a 1000+ line function into a well-organized, modular architecture with: +- โœ… Clear separation of concerns +- โœ… Easy navigation and maintenance +- โœ… Better error handling +- โœ… Improved testability +- โœ… No breaking changes + +**Result:** The codebase is now much easier to understand, modify, and extend. diff --git a/docs/development/REFACTORING_NOTES.md b/docs/development/REFACTORING_NOTES.md new file mode 100644 index 0000000..25cdecf --- /dev/null +++ b/docs/development/REFACTORING_NOTES.md @@ -0,0 +1,127 @@ +# Code Refactoring - Handler Organization + +## Overview +The `bot/handlers.py` file has been refactored to improve code organization, readability, and maintainability. The massive `button_handler` function (previously 1000+ lines) has been broken down into logical, focused modules. + +## New Structure + +### Main Module: `bot/handlers.py` +**Purpose:** Core routing and utility functions +- `send_or_edit_with_image()` - Image handling utility +- `start()` - /start command handler +- `export_map()` - Admin command for map export +- `spawn_stats()` - Admin command for spawn statistics +- `button_handler()` - Main router that delegates to specific handlers + +### Action Handlers: `bot/action_handlers.py` +**Purpose:** World interaction and inspection +- `get_player_status_text()` - Generate player status display +- `handle_inspect_area()` - Inspect current location +- `handle_attack_wandering()` - Attack wandering enemies +- `handle_inspect_interactable()` - Inspect objects +- `handle_action()` - Perform actions on interactables +- `handle_main_menu()` - Return to main menu +- `handle_move_menu()` - Show movement options +- `handle_move()` - Handle player movement + +### Inventory Handlers: `bot/inventory_handlers.py` +**Purpose:** Inventory management +- `handle_inventory_menu()` - Show inventory +- `handle_inventory_item()` - Show item details +- `handle_inventory_use()` - Use consumable items +- `handle_inventory_drop()` - Drop items to world +- `handle_inventory_equip()` - Equip items +- `handle_inventory_unequip()` - Unequip items + +### Pickup Handlers: `bot/pickup_handlers.py` +**Purpose:** Item collection from world +- `handle_pickup_menu()` - Show pickup options +- `handle_pickup()` - Pick up dropped items + +### Combat Handlers: `bot/combat_handlers.py` +**Purpose:** Combat actions +- `handle_combat_attack()` - Attack enemy +- `handle_combat_flee()` - Attempt to flee +- `handle_combat_use_item_menu()` - Show usable items in combat +- `handle_combat_use_item()` - Use item during combat +- `handle_combat_back()` - Return to combat menu + +### Profile Handlers: `bot/profile_handlers.py` +**Purpose:** Character profile and stat management +- `handle_profile()` - Show player profile +- `handle_spend_points_menu()` - Show stat point menu +- `handle_spend_point()` - Spend stat points + +### Corpse Handlers: `bot/corpse_handlers.py` +**Purpose:** Looting corpses +- `handle_loot_player_corpse()` - Show player corpse loot +- `handle_take_corpse_item()` - Take item from player corpse +- `handle_scavenge_npc_corpse()` - Show NPC corpse loot +- `handle_scavenge_corpse_item()` - Scavenge from NPC corpse + +## Benefits + +### 1. **Improved Readability** +- Each module focuses on a specific domain +- Function names clearly describe their purpose +- Easier to find and understand specific functionality + +### 2. **Better Maintainability** +- Changes to one feature don't affect others +- Easier to test individual components +- Reduced risk of merge conflicts + +### 3. **Logical Organization** +- Related functions grouped together +- Clear separation of concerns +- Follows Single Responsibility Principle + +### 4. **Easier Navigation** +- Jump to specific functionality quickly +- No need to scroll through 1000+ lines +- Clear module imports show dependencies + +### 5. **Error Handling** +- Centralized error handling in router +- Consistent error messages +- Better logging + +## Migration Notes + +### Old Structure +```python +async def button_handler(): + # 1000+ lines of if/elif statements + if action_type == "inspect_area": + # 50 lines of code + elif action_type == "inventory_menu": + # 30 lines of code + # ... hundreds more lines +``` + +### New Structure +```python +async def button_handler(): + # Clean router - delegates to specialized handlers + if action_type == "inspect_area": + await handle_inspect_area(query, user_id, player) + elif action_type == "inventory_menu": + await handle_inventory_menu(query, user_id, player) +``` + +## Future Improvements + +1. **Add unit tests** for each handler module +2. **Create handler base class** for common functionality +3. **Add type hints** for better IDE support +4. **Document return types** and error conditions +5. **Add handler decorators** for common validations + +## Backup Files + +Old versions have been preserved in `backups/`: +- `handlers_original.py` - Last version before refactoring +- `handlers.py.old` - Additional backup + +## Date +Refactored: October 19, 2025 diff --git a/docs/development/UI_EXAMPLES.md b/docs/development/UI_EXAMPLES.md new file mode 100644 index 0000000..2285076 --- /dev/null +++ b/docs/development/UI_EXAMPLES.md @@ -0,0 +1,240 @@ +# Visual UI Examples + +## Before & After Comparison + +### Main Menu / Player Status + +#### Before +``` +Location: Downtown Plaza +Status: Healthy +โค๏ธ HP: 70/100 | โšก๏ธ Stamina: 50/100 +๐ŸŽ’ Load: 15/50 kg | 30/100 vol +โš”๏ธ Equipped: ๐Ÿ”ง Wrench, ๐ŸŽ’ Backpack +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” +A desolate plaza, once bustling with life... +``` + +#### After +``` +๐Ÿ“ Location: Downtown Plaza +โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 70% (70/100) +โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100) +๐ŸŽ’ Load: 15/50 kg | 30/100 vol +โš”๏ธ Equipped: ๐Ÿ”ง Wrench, ๐ŸŽ’ Backpack +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” +A desolate plaza, once bustling with life... +``` + +### Player Profile + +#### Before +``` +๐Ÿ‘ค PlayerName +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + +Level: 5 +XP: 240/600 (40%) +โญ Unspent Points: 2 + +Health: 70/100 โค๏ธ +Stamina: 50/100 โšก + +Stats: +๐Ÿ’ช Strength: 12 +๐Ÿƒ Agility: 8 +๐Ÿ’š Endurance: 10 +๐Ÿง  Intellect: 5 + +Combat: +โš”๏ธ Base Damage: 13 +๐Ÿ›ก๏ธ Flee Chance: 58% +๐Ÿ’š Stamina Regen: 2/cycle +``` + +#### After +``` +๐Ÿ‘ค PlayerName +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + +Level: 5 +โญ XP: โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 40% (240/600) +๐Ÿ’Ž Unspent Points: 2 + +โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 70% (70/100) +โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100) + +Stats: +๐Ÿ’ช Strength: 12 +๐Ÿƒ Agility: 8 +๐Ÿ’š Endurance: 10 +๐Ÿง  Intellect: 5 + +Combat: +โš”๏ธ Base Damage: 13 +๐Ÿ›ก๏ธ Flee Chance: 58% +๐Ÿ’š Stamina Regen: 2/cycle +``` + +## Visual States + +### Critical Health +``` +โค๏ธ HP: โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 20% (20/100) +``` + +### Low Stamina +``` +โšก Stamina: โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 20% (20/100) +``` + +### Half Values +``` +โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100) +โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100) +``` + +### Full Values +``` +โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 100% (100/100) +โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ 100% (100/100) +``` + +### Empty/Dead +``` +โค๏ธ HP: โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 0% (0/100) +โšก Stamina: โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 0% (0/100) +``` + +## XP Progress Examples + +### Just Leveled Up +``` +โญ XP: โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 0% (0/600) +``` + +### Quarter Progress +``` +โญ XP: โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 25% (150/600) +``` + +### Half Progress +``` +โญ XP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (300/600) +``` + +### Almost Level Up +``` +โญ XP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘ 95% (570/600) +``` + +## Character Build Examples + +### Tank Build (High HP/Endurance) +``` +๐Ÿ‘ค TankWarrior +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + +Level: 10 +โญ XP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 65% (780/1200) + +โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ 80% (160/200) +โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘ 60% (90/150) + +Stats: +๐Ÿ’ช Strength: 15 +๐Ÿƒ Agility: 5 +๐Ÿ’š Endurance: 20 +๐Ÿง  Intellect: 5 +``` + +### Glass Cannon (High Damage, Low HP) +``` +๐Ÿ‘ค GlassCannon +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + +Level: 10 +โญ XP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 65% (780/1200) + +โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘ 60% (60/100) +โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ 80% (120/150) + +Stats: +๐Ÿ’ช Strength: 25 +๐Ÿƒ Agility: 10 +๐Ÿ’š Endurance: 5 +๐Ÿง  Intellect: 5 +``` + +### Balanced Build +``` +๐Ÿ‘ค AllRounder +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + +Level: 10 +โญ XP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 65% (780/1200) + +โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 70% (105/150) +โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 70% (88/125) + +Stats: +๐Ÿ’ช Strength: 15 +๐Ÿƒ Agility: 12 +๐Ÿ’š Endurance: 12 +๐Ÿง  Intellect: 6 +``` + +## Combat Display Examples + +### Player vs Enemy (Active Combat) +``` +โš”๏ธ Combat with ๐Ÿ• Feral Dog! + +A mangy, aggressive dog with matted fur... + +๐Ÿ• Enemy HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘ 60% (30/50) +โค๏ธ Your HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 70% (70/100) + +๐ŸŽฏ Your turn! What will you do? + +[โš”๏ธ Attack] [๐Ÿƒ Flee] [๐Ÿ’Š Use Item] +``` + +### Low Health Warning +``` +โš ๏ธ A ๐ŸงŸ Infected Human appears! + +A shambling, infected survivor... + +๐ŸงŸ Enemy HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘ 80% (80/100) +โค๏ธ Your HP: โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 20% (20/100) โš ๏ธ CRITICAL! + +๐ŸŽฏ Your turn! What will you do? +``` + +## Inventory Display + +### Inventory Load Bars (Future Enhancement) +``` +๐ŸŽ’ Your Inventory: +๐Ÿ“Š Weight: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (25/50 kg) +๐Ÿ“ฆ Volume: โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 30% (30/100 vol) + +Items: +๐Ÿ”ง Wrench x1 +๐Ÿฅซ Canned Food x3 +๐Ÿ’Š Bandage x5 +``` + +## Mobile Display + +All bars are designed to work perfectly on mobile: +- Unicode characters supported on all platforms +- Monospaced alignment for consistent display +- Clear even on small screens +- Works in all Telegram clients + +--- + +**Implementation Date:** October 19, 2025 +**Status:** โœ… Live in Production diff --git a/docs/development/VISUAL_IMPROVEMENTS.md b/docs/development/VISUAL_IMPROVEMENTS.md new file mode 100644 index 0000000..fa7cd0b --- /dev/null +++ b/docs/development/VISUAL_IMPROVEMENTS.md @@ -0,0 +1,297 @@ +# Visual UI Improvements + +## Overview +This document describes the visual improvements made to the game's user interface, particularly the addition of progress bars for stats. + +## Progress Bars + +### Implementation +Visual progress bars have been added to display HP, Stamina, and XP using Unicode block characters. + +### Format +``` +โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 70% (70/100) +โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100) +โญ XP: โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 40% (240/600) +``` + +### Components +- **Emoji** - Visual indicator of stat type +- **Label** - Stat name (HP, Stamina, etc.) +- **Progress Bar** - Visual representation using โ–ˆ (filled) and โ–‘ (empty) +- **Percentage** - Numeric percentage value +- **Values** - Current/Maximum in parentheses + +### Characters Used +- `โ–ˆ` (U+2588) - Full Block - Represents filled portion +- `โ–‘` (U+2591) - Light Shade - Represents empty portion + +## Utility Functions + +### `create_progress_bar()` +Creates a visual progress bar from current and maximum values. + +**Signature:** +```python +def create_progress_bar( + current: int, + maximum: int, + length: int = 10, + filled_char: str = "โ–ˆ", + empty_char: str = "โ–‘" +) -> str +``` + +**Parameters:** +- `current` - Current value of the stat +- `maximum` - Maximum possible value +- `length` - Number of characters in the bar (default: 10) +- `filled_char` - Character for filled portion (default: โ–ˆ) +- `empty_char` - Character for empty portion (default: โ–‘) + +**Returns:** +String containing the progress bar + +**Examples:** +```python +>>> create_progress_bar(75, 100) +"โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘" + +>>> create_progress_bar(0, 100) +"โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘" + +>>> create_progress_bar(100, 100) +"โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ" + +>>> create_progress_bar(33, 100, length=6) +"โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘" +``` + +### `format_stat_bar()` +Formats a complete stat line with label, bar, and values. + +**Signature:** +```python +def format_stat_bar( + label: str, + emoji: str, + current: int, + maximum: int, + bar_length: int = 10 +) -> str +``` + +**Parameters:** +- `label` - Name of the stat (e.g., "HP", "Stamina") +- `emoji` - Emoji to display (e.g., "โค๏ธ", "โšก") +- `current` - Current value +- `maximum` - Maximum value +- `bar_length` - Length of the progress bar (default: 10) + +**Returns:** +Formatted string with emoji, label, bar, percentage, and values + +**Examples:** +```python +>>> format_stat_bar("HP", "โค๏ธ", 75, 100) +"โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 75% (75/100)" + +>>> format_stat_bar("Stamina", "โšก", 50, 100) +"โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100)" + +>>> format_stat_bar("XP", "โญ", 240, 600) +"โญ XP: โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 40% (240/600)" +``` + +## Updated Displays + +### Player Status (Main Menu) +**Before:** +``` +Location: Downtown Plaza +Status: Healthy +โค๏ธ HP: 70/100 | โšก๏ธ Stamina: 50/100 +๐ŸŽ’ Load: 5/50 kg | 10/100 vol +``` + +**After:** +``` +๐Ÿ“ Location: Downtown Plaza +โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 70% (70/100) +โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100) +๐ŸŽ’ Load: 5/50 kg | 10/100 vol +``` + +### Player Profile +**Before:** +``` +๐Ÿ‘ค PlayerName +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + +Level: 5 +XP: 240/600 (40%) + +Health: 70/100 โค๏ธ +Stamina: 50/100 โšก +``` + +**After:** +``` +๐Ÿ‘ค PlayerName +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + +Level: 5 +โญ XP: โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 40% (240/600) + +โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 70% (70/100) +โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100) +``` + +## Benefits + +### 1. **Visual Clarity** +- Instant understanding of stat levels at a glance +- No mental math required to assess health/stamina +- Color-coded through emojis (โค๏ธ = health, โšก = stamina) + +### 2. **Better Gameplay** +- Players can quickly assess danger +- Easy to see when healing/rest is needed +- Visual feedback is more engaging + +### 3. **Mobile Friendly** +- Unicode characters work on all platforms +- No images required +- Works in all Telegram clients + +### 4. **Accessibility** +- Percentage and numeric values still provided +- Multiple representations of same data +- Screen readers can parse text values + +## Customization + +### Changing Bar Length +Modify the `bar_length` parameter: +```python +# Shorter bars (5 characters) +format_stat_bar("HP", "โค๏ธ", 75, 100, bar_length=5) +# Output: "โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–‘โ–‘ 75% (75/100)" + +# Longer bars (20 characters) +format_stat_bar("HP", "โค๏ธ", 75, 100, bar_length=20) +# Output: "โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 75% (75/100)" +``` + +### Alternative Characters +Different visual styles can be achieved with different characters: + +**Style 1 - Blocks (Default):** +```python +create_progress_bar(50, 100, filled_char="โ–ˆ", empty_char="โ–‘") +# Output: "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘" +``` + +**Style 2 - Squares:** +```python +create_progress_bar(50, 100, filled_char="โ– ", empty_char="โ–ก") +# Output: "โ– โ– โ– โ– โ– โ–กโ–กโ–กโ–กโ–ก" +``` + +**Style 3 - Circles:** +```python +create_progress_bar(50, 100, filled_char="โ—", empty_char="โ—‹") +# Output: "โ—โ—โ—โ—โ—โ—‹โ—‹โ—‹โ—‹โ—‹" +``` + +**Style 4 - Bars:** +```python +create_progress_bar(50, 100, filled_char="โ–ฐ", empty_char="โ–ฑ") +# Output: "โ–ฐโ–ฐโ–ฐโ–ฐโ–ฐโ–ฑโ–ฑโ–ฑโ–ฑโ–ฑ" +``` + +## Color Variations + +While Telegram doesn't support custom colors in text, we use emoji for color coding: + +- โค๏ธ Red heart = Health (HP) +- โšก Lightning = Energy (Stamina) +- โญ Star = Experience (XP) +- ๐ŸŽ’ Backpack = Inventory capacity +- ๐Ÿ’Ž Gem = Unspent points + +## Performance + +### Optimization +- Progress bars are generated on-demand +- No database storage required +- Minimal computation (simple division and multiplication) +- No external dependencies + +### Caching +Progress bars are not cached as they change frequently and are cheap to generate. + +## Future Enhancements + +### Potential Improvements +1. **Color thresholds** - Different bars at low health + ``` + โค๏ธ HP: โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 20% (20/100) โš ๏ธ LOW + ``` + +2. **Animated transitions** - Show changes over time + (Limited by Telegram's edit rate limits) + +3. **More stats** - Apply to other numeric values + - Hunger bar + - Thirst bar + - Status effect duration + +4. **Inventory load bars** + ``` + ๐ŸŽ’ Weight: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (25/50 kg) + ๐Ÿ“ฆ Volume: โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 30% (30/100 vol) + ``` + +## Testing + +### Test Cases +```python +# Test edge cases +assert create_progress_bar(0, 100) == "โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘" +assert create_progress_bar(100, 100) == "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ" +assert create_progress_bar(50, 100) == "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘" + +# Test invalid values +assert create_progress_bar(-10, 100) == "โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘" # Negative clamped to 0 +assert create_progress_bar(150, 100) == "โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ" # Over max clamped to 100% + +# Test zero maximum +assert create_progress_bar(10, 0) == "โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘" # Avoid division by zero +``` + +## Files Modified + +- **`bot/utils.py`** - Added utility functions +- **`bot/action_handlers.py`** - Updated `get_player_status_text()` +- **`bot/profile_handlers.py`** - Updated `handle_profile()` + +## Migration + +No database migration required - this is purely a display change. + +## Rollback + +To revert to the old format, simply remove the `format_stat_bar()` calls and use plain text: +```python +# Old format +status += f"โค๏ธ HP: {player['hp']}/{player['max_hp']}\n" + +# New format (can be reverted) +status += f"{format_stat_bar('HP', 'โค๏ธ', player['hp'], player['max_hp'])}\n" +``` + +--- + +**Implementation Date:** October 19, 2025 +**Status:** โœ… Complete and Deployed diff --git a/docs/game/MECHANICS.md b/docs/game/MECHANICS.md new file mode 100644 index 0000000..444c136 --- /dev/null +++ b/docs/game/MECHANICS.md @@ -0,0 +1,221 @@ +# Game Mechanics Overview + +## Core Systems + +### 1. Health & Stamina System + +#### Health Points (HP) +- **Purpose:** Measure of character's life force +- **Starting Value:** 100 HP +- **Range:** 0 to max_hp +- **Death:** Occurs when HP reaches 0 +- **Regeneration:** Does not auto-regenerate (requires items or rest) +- **Display:** Visual bar with percentage + ``` + โค๏ธ HP: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘ 70% (70/100) + ``` + +#### Stamina +- **Purpose:** Resource for actions and movement +- **Starting Value:** 100 Stamina +- **Range:** 0 to max_stamina +- **Usage:** + - Movement between locations (varies by distance and inventory weight) + - Actions on interactables (searching, opening, etc.) +- **Regeneration:** Passive over time (1 + endurance/10 per cycle) +- **Display:** Visual bar with percentage + ``` + โšก Stamina: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘ 50% (50/100) + ``` + +### 2. Character Progression + +#### Experience (XP) +- **Gained From:** + - Defeating enemies + - Completing actions + - Exploring new locations +- **Display:** Progress bar showing advancement to next level + ``` + โญ XP: โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 40% (240/600) + ``` + +#### Leveling +- **Formula:** XP required = 100 * (level ^ 1.5) +- **Benefits:** + - Stat point to allocate + - Increased base damage + - Access to new areas + +#### Stats +- **Strength (๐Ÿ’ช)** + - Increases melee damage + - Formula: Base Damage = 5 + (strength / 2) + level + +- **Agility (๐Ÿƒ)** + - Increases flee chance in combat + - Formula: Flee Chance = 50% + (agility / 100) + +- **Endurance (๐Ÿ’š)** + - Increases max HP when leveled + - Increases stamina regeneration + - Formula: Stamina Regen = 1 + (endurance / 10) + +- **Intellect (๐Ÿง )** + - Reserved for future mechanics + - May affect crafting, dialogue, etc. + +#### Stat Points +- **Earned:** 1 point per level +- **Allocation Options:** + - +10 Max HP + - +5 Max Stamina + - +1 Strength + - +1 Agility + - +1 Endurance + - +1 Intellect + +### 3. Inventory System + +#### Capacity +- **Weight Limit:** 50 kg (base) + equipped backpack bonus +- **Volume Limit:** 100 vol (base) + equipped backpack bonus +- **Overencumbered:** Cannot pick up items when at capacity +- **Movement Penalty:** Higher inventory weight increases stamina cost for travel + +#### Item Types +- **Weapons:** Equippable, adds damage +- **Armor:** Equippable, adds protection +- **Consumables:** Single-use, restores HP/Stamina +- **Tools:** Required for certain actions +- **Materials:** Crafting components (future) +- **Quest Items:** Special items for progression + +#### Equipment Slots +- **Weapon:** Primary attack tool +- **Backpack:** Increases carrying capacity +- **Armor:** Protection (future implementation) + +### 4. Combat System + +#### Turn-Based Combat +- Player turn โ†’ NPC turn โ†’ Repeat until victory or defeat + +#### Actions +- **Attack:** Deal damage to enemy +- **Flee:** Attempt to escape (chance-based) +- **Use Item:** Consume healing/buff items + +#### Damage Calculation +```python +Base Damage = 5 + (strength / 2) + level +Weapon Damage = random(weapon_min, weapon_max) +Total Damage = Base Damage + Weapon Damage +``` + +#### Enemy Spawning +- **Static Spawns:** Fixed enemies at locations +- **Random Encounters:** Chance-based when moving +- **Wandering Enemies:** NPCs that patrol locations + +#### Death & Respawn +- **Player Death:** Lose all inventory, respawn at start location +- **Corpses:** Player corpses can be looted by others (multiplayer aspect) +- **Enemy Death:** Drop loot, award XP + +### 5. World Interaction + +#### Locations +- **Types:** Safe zones, dangerous areas, dungeons +- **Connections:** Graph-based navigation +- **Images:** Visual representation of each location +- **Interactables:** Objects to search/interact with + +#### Actions +- **Inspect Area:** View location details and items +- **Move:** Travel to connected locations +- **Search Objects:** Loot interactables for items +- **Attack Enemies:** Engage wandering NPCs + +#### Cooldowns +- **Purpose:** Prevent infinite resource farming +- **Duration:** Varies by action (typically 5-30 minutes) +- **Per-Object:** Each interactable has independent cooldown + +### 6. Item Collection + +#### Dropped Items +- **Sources:** + - Enemy loot + - Interactable rewards + - Player-dropped items +- **Pickup:** Choose quantity to take +- **Capacity Check:** Must have room in inventory + +#### Looting +- **NPC Corpses:** + - Requires tools for certain materials + - Random quantities + - Disappears when fully looted + +- **Player Corpses:** + - Contains all inventory from death + - Can be looted by any player + - Encourages retrieval runs + +## Game Loop + +### Basic Cycle +1. **Explore** โ†’ Move to new location +2. **Inspect** โ†’ Find interactables and enemies +3. **Action** โ†’ Search, fight, or collect +4. **Manage** โ†’ Organize inventory, use items +5. **Progress** โ†’ Gain XP, level up, allocate stats +6. **Repeat** + +### Resource Management +- Monitor HP and Stamina +- Use consumables strategically +- Rest when needed +- Manage inventory weight + +### Risk vs. Reward +- Dangerous areas have better loot +- Higher-level enemies give more XP +- Overextending can lead to death +- Strategic retreats preserve progress + +## Progression Path + +### Early Game (Levels 1-5) +- Learn basic mechanics +- Gather starting equipment +- Explore safe zones +- Build initial stats + +### Mid Game (Levels 6-15) +- Venture into dangerous areas +- Face tougher enemies +- Collect better equipment +- Specialize character build + +### Late Game (Levels 16+) +- Challenge end-game content +- Optimize builds +- Collect rare items +- Explore all areas + +## Future Mechanics + +### Planned Features +- **Crafting System:** Combine materials into items +- **Quests:** Story-driven objectives +- **Factions:** Reputation and alliances +- **Trading:** Player-to-player economy +- **Skills:** Special abilities beyond stats +- **Housing:** Personal storage and rest areas + +--- + +**Last Updated:** October 19, 2025 +**Status:** Living document - Updated as mechanics evolve