Files
echoes-of-the-ash/bot/action_handlers.py
Joan c0783340b0 Unify all handler signatures and simplify router
- Standardize all handlers to signature: (query, user_id, player, data=None)
- Replace 125-line if/elif chain with HANDLER_MAP dictionary
- Reduce router code by 90 lines (72% reduction)
- Add HANDLER_MAP registry for cleaner organization
- Enable future auto-discovery and decorator patterns
- Maintain full backward compatibility
- Document changes in HANDLER_REFACTORING_V2.md

Benefits:
- O(1) handler lookup vs O(n) if/elif chain
- Add new handlers by just updating the map
- Consistent signature makes code easier to understand
- Opens doors for middleware and hooks
2025-10-20 12:22:07 +02:00

371 lines
14 KiB
Python

"""
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 .utils import format_stat_bar
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."""
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"<b>📍 Location:</b> {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"🎒 <b>Load:</b> {weight}/{max_weight} kg | {volume}/{max_volume} vol\n"
if equipped_items:
status += f"⚔️ <b>Equipped:</b> {', '.join(equipped_items)}\n"
status += f"━━━━━━━━━━━━━━━━━━━━\n<i>{location.description}</i>"
return status
# ============================================================================
# INSPECTION & WORLD INTERACTION HANDLERS
# ============================================================================
async def handle_inspect_area(query, user_id: int, player: dict, data: list = None):
"""Handle inspect area action - show NPCs and interactables in current location."""
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 += format_stat_bar(f"{npc_def.emoji} Enemy HP", "", combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n"
message += format_stat_bar("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"<i>{outcome.text}</i>"]
if action_obj.stamina_cost > 0:
result_details.append(f"⚡️ <b>Stamina:</b> -{action_obj.stamina_cost}")
if outcome.damage_taken > 0:
result_details.append(f"❤️ <b>HP:</b> -{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"🎁 <b>Gained:</b> {', '.join(items_text)}")
if items_failed:
result_details.append(f"⚠️ <b>Couldn't take:</b> {', '.join(items_failed)}")
final_text = await get_player_status_text(user_id)
final_text += f"\n\n<b>━━━ Action Result ━━━</b>\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, data: list = None):
"""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, data: list = None):
"""Show movement options menu."""
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 += format_stat_bar(f"{npc_def.emoji} Enemy HP", "", combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n"
message += format_stat_bar("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
)