Files
echoes-of-the-ash/bot/handlers.py

1269 lines
59 KiB
Python

import logging
import math
import json
import random
from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton
from telegram.ext import ContextTypes
from telegram.error import BadRequest
from . import database, keyboards, logic
from .utils import admin_only
from data.world_loader import game_world
from data.items import ITEMS
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"<b>Location:</b> {location.name}\n<b>Status:</b> Healthy\n"
status += f"❤️ <b>HP:</b> {player['hp']}/{player['max_hp']} | ⚡️ <b>Stamina:</b> {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
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)
if image_path:
# Get or upload image
cached_file_id = await database.get_cached_image(image_path)
if not cached_file_id and os.path.exists(image_path):
# Upload new image
try:
with open(image_path, 'rb') as img_file:
temp_msg = await current_message.reply_photo(
photo=img_file,
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
if temp_msg.photo:
cached_file_id = temp_msg.photo[-1].file_id
await database.cache_image(image_path, cached_file_id)
# Delete old message to keep chat clean
try:
await current_message.delete()
except:
pass
return
except Exception as e:
logger.error(f"Error uploading image: {e}")
cached_file_id = None
if cached_file_id:
# Check if current message has same photo
if has_photo:
current_file_id = current_message.photo[-1].file_id
if current_file_id == cached_file_id:
# Same image, just edit caption
try:
await query.edit_message_caption(
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
return
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:
media = InputMediaPhoto(
media=cached_file_id,
caption=text,
parse_mode=parse_mode
)
await query.edit_message_media(
media=media,
reply_markup=reply_markup
)
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
if not has_photo:
# Can't edit text message to photo message, need to delete and send new
try:
await current_message.delete()
except:
pass
try:
await current_message.reply_photo(
photo=cached_file_id,
caption=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
except Exception as e:
logger.error(f"Error sending cached image: {e}")
else:
# No image requested
if has_photo:
# Current message has photo, need to delete and send text-only
try:
await current_message.delete()
except:
pass
await current_message.reply_html(text=text, reply_markup=reply_markup)
else:
# Both text-only, just edit
try:
await query.edit_message_text(text=text, reply_markup=reply_markup, parse_mode=parse_mode)
except BadRequest as e:
if "Message is not modified" not in str(e):
await current_message.reply_html(text=text, reply_markup=reply_markup)
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
import os
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.")
# Get player status and location image
player = await database.get_player(user.id)
status_text = await get_player_status_text(user.id)
location = game_world.get_location(player['location_id'])
# Send with image if available
if location and location.image_path:
cached_file_id = await database.get_cached_image(location.image_path)
if cached_file_id:
await update.message.reply_photo(
photo=cached_file_id,
caption=status_text,
reply_markup=keyboards.main_menu_keyboard(),
parse_mode='HTML'
)
elif os.path.exists(location.image_path):
with open(location.image_path, 'rb') as img_file:
msg = await update.message.reply_photo(
photo=img_file,
caption=status_text,
reply_markup=keyboards.main_menu_keyboard(),
parse_mode='HTML'
)
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())
else:
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
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"
await update.message.reply_document(
document=file,
filename="map_data.json",
caption="🗺️ Game Map Data\n\nThis JSON file contains all locations, coordinates, and connections.\nYou can use it to visualize the game map in external tools."
)
@admin_only
async def spawn_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Show wandering enemy spawn statistics (debug command)."""
from bot.spawn_manager import get_spawn_stats
stats = await get_spawn_stats()
text = "📊 <b>Wandering Enemy Statistics</b>\n\n"
text += f"<b>Total Active Enemies:</b> {stats['total_active']}\n\n"
if stats['by_location']:
text += "<b>Enemies by Location:</b>\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"
else:
text += "<i>No wandering enemies currently active.</i>"
await update.message.reply_html(text)
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
user_id = query.from_user.id
data = query.data.split(':')
action_type = data[0]
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)
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']:
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)
# Get wandering enemies from database
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)
elif action_type == "attack_wandering":
# Player initiates combat with 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
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 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"<i>{outcome.text}</i>")
# Add stamina cost
if action_obj.stamina_cost > 0:
result_details.append(f"⚡️ <b>Stamina:</b> -{action_obj.stamina_cost}")
# Add HP damage if any
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():
# 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"🎁 <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
await send_or_edit_with_image(query, text=final_text, reply_markup=keyboards.main_menu_keyboard(), image_path=location_image)
# ... (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"👤 <b>{player['name']}</b>\n"
profile_text += f"━━━━━━━━━━━━━━━━━━━━\n\n"
profile_text += f"<b>Level:</b> {player['level']}\n"
profile_text += f"<b>XP:</b> {xp_current}/{xp_needed} ({progress_percent}%)\n"
if unspent > 0:
profile_text += f"⭐ <b>Unspent Points:</b> {unspent}\n"
profile_text += f"\n<b>Health:</b> {player['hp']}/{player['max_hp']} ❤️\n"
profile_text += f"<b>Stamina:</b> {player['stamina']}/{player['max_stamina']}\n\n"
profile_text += f"<b>Stats:</b>\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"<b>Combat:</b>\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"⭐ <b>Spend Stat Points</b>\n\n"
text += f"Available Points: <b>{unspent}</b>\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"⭐ <b>Spend Stat Points</b>\n\n"
text += f"Available Points: <b>{new_unspent}</b>\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} <b>{item_def.get('name', 'Unknown')}</b>\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 = "<b>🎒 Your Inventory:</b>\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} <b>{item_def.get('name', 'Unknown')}</b>\n"
# Add description if available
description = item_def.get('description')
if description:
text += f"<i>{description}</i>\n\n"
else:
text += "\n"
text += f"<b>Weight:</b> {item_def.get('weight', 0)} kg | <b>Volume:</b> {item_def.get('volume', 0)} vol\n"
# Add weapon stats if applicable
if item_def.get('type') == 'weapon':
text += f"<b>Damage:</b> {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"<b>Effects:</b> {', '.join(effects)}\n"
# Add equipped status
if item.get('is_equipped'):
text += "\n✅ <b>Currently Equipped</b>"
# 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"<b>Used {emoji} {item_def.get('name')}</b>\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 = "<b>🎒 Your Inventory:</b>\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 = "<b>🎒 Your Inventory:</b>\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"<b>Item:</b> {emoji} {item_def.get('name', 'Unknown')}\n"
text += f"<b>Weight:</b> {item_def.get('weight', 0)} kg | <b>Volume:</b> {item_def.get('volume', 0)} vol\n"
if item_def.get('type') == 'weapon':
text += f"<b>Damage:</b> {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n"
text += "\n✅ <b>Currently Equipped</b>"
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"<b>Item:</b> {emoji} {item_def.get('name', 'Unknown')}\n"
text += f"<b>Weight:</b> {item_def.get('weight', 0)} kg | <b>Volume:</b> {item_def.get('volume', 0)} vol\n"
if item_def.get('type') == 'weapon':
text += f"<b>Damage:</b> {item_def.get('damage_min', 0)}-{item_def.get('damage_max', 0)}\n"
text += "\n✅ <b>Currently Equipped</b>"
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"<b>Item:</b> {emoji} {item_def.get('name', 'Unknown')}\n"
text += f"<b>Weight:</b> {item_def.get('weight', 0)} kg | <b>Volume:</b> {item_def.get('volume', 0)} vol\n"
if item_def.get('type') == 'weapon':
text += f"<b>Damage:</b> {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"<b>Item:</b> {emoji} {item_def.get('name', 'Unknown')}\n"
text += f"<b>Weight:</b> {item_def.get('weight', 0)} kg | <b>Volume:</b> {item_def.get('volume', 0)} vol\n"
if item_def.get('type') == 'weapon':
text += f"<b>Damage:</b> {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)