""" Combat system logic for turn-based NPC encounters. """ import random import json import time from typing import Dict, List, Tuple, Optional from bot import database from data.npcs import NPCS, STATUS_EFFECTS from data.items import ITEMS # XP curve for leveling def xp_for_level(level: int) -> int: """Calculate XP needed to reach a level.""" if level <= 1: return 0 # Level 1 starts at 0 XP return int(100 * (level ** 1.5)) async def calculate_player_damage(player: dict) -> int: """Calculate player's damage output based on stats and equipped weapon.""" base_damage = 5 strength_bonus = player['strength'] // 2 level_bonus = player['level'] # Check for equipped weapon inventory = await database.get_inventory(player['telegram_id']) weapon_damage = 0 for item in inventory: if item.get('is_equipped'): item_def = ITEMS.get(item['item_id'], {}) if item_def.get('type') == 'weapon': # Get weapon damage range damage_min = item_def.get('damage_min', 0) damage_max = item_def.get('damage_max', 0) weapon_damage = random.randint(damage_min, damage_max) break # Random variance variance = random.randint(-2, 2) return max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) def calculate_npc_damage(npc_def, npc_hp: int, npc_max_hp: int) -> int: """Calculate NPC's damage output.""" base_damage = random.randint(npc_def.damage_min, npc_def.damage_max) # Enraged bonus if low HP hp_percent = npc_hp / npc_max_hp if hp_percent < 0.3: base_damage = int(base_damage * 1.5) return max(1, base_damage) async def initiate_combat(player_id: int, npc_id: str, location_id: str, from_wandering_enemy: bool = False) -> Dict: """ Start a new combat encounter. Args: player_id: Telegram user ID npc_id: NPC definition ID location_id: Where combat is happening from_wandering_enemy: If True, enemy will respawn if player flees or dies Returns combat state dict. """ npc_def = NPCS.get(npc_id) if not npc_def: return None # Randomize NPC HP npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max) # Create combat in database combat_id = await database.create_combat( player_id=player_id, npc_id=npc_id, npc_hp=npc_hp, npc_max_hp=npc_hp, location_id=location_id, from_wandering_enemy=from_wandering_enemy ) return await database.get_combat(player_id) async def player_attack(player_id: int) -> Tuple[str, bool, bool]: """ Player attacks the NPC. Returns: (message, npc_died, player_turn_ended) """ combat = await database.get_combat(player_id) if not combat or combat['turn'] != 'player': return ("It's not your turn!", False, False) player = await database.get_player(player_id) npc_def = NPCS.get(combat['npc_id']) if not player or not npc_def: return ("Combat error!", False, False) # Check if player is stunned player_effects = json.loads(combat['player_status_effects']) is_stunned = any(effect['name'] == 'Stunned' for effect in player_effects) if is_stunned: # Update status effects player_effects = update_status_effects(player_effects) await database.update_combat(player_id, { 'turn': 'npc', 'turn_started_at': time.time(), 'player_status_effects': json.dumps(player_effects) }) return ("āš ļø You're stunned and cannot attack! The enemy seizes the opportunity!", False, True) # Calculate damage raw_damage = await calculate_player_damage(player) actual_damage = max(1, raw_damage - npc_def.defense) new_npc_hp = max(0, combat['npc_hp'] - actual_damage) # Check for critical hit (10% chance) is_crit = random.random() < 0.1 if is_crit: actual_damage = int(actual_damage * 1.5) new_npc_hp = max(0, combat['npc_hp'] - actual_damage) message = f"āš”ļø You attack the {npc_def.name} for {actual_damage} damage!" if is_crit: message += " šŸ’„ CRITICAL HIT!" # Check for status effect infliction (5% chance to stun) npc_effects = json.loads(combat['npc_status_effects']) if random.random() < 0.05: npc_effects.append({ 'name': 'Stunned', 'turns_remaining': 1, 'damage_per_turn': 0 }) message += f"\n🌟 You stunned the {npc_def.name}!" # Apply status effect damage to player player_effects, status_damage, status_messages = apply_status_effects(player_effects) if status_damage > 0: new_player_hp = max(0, player['hp'] - status_damage) await database.update_player(player_id, {'hp': new_player_hp}) message += f"\n{status_messages}" if new_player_hp <= 0: await handle_player_death(player_id) return (message + "\n\nšŸ’€ You have died from your wounds...", True, True) # Check if NPC died if new_npc_hp <= 0: await database.update_combat(player_id, { 'npc_hp': 0, 'npc_status_effects': json.dumps(npc_effects), 'player_status_effects': json.dumps(player_effects) }) # Handle victory victory_msg = await handle_npc_death(player_id, combat, npc_def) return (message + "\n\n" + victory_msg, True, True) # Update combat - switch to NPC turn await database.update_combat(player_id, { 'npc_hp': new_npc_hp, 'turn': 'npc', 'turn_started_at': time.time(), 'npc_status_effects': json.dumps(npc_effects), 'player_status_effects': json.dumps(player_effects) }) message += f"\n{npc_def.emoji} {npc_def.name}: {new_npc_hp}/{combat['npc_max_hp']} HP" return (message, False, True) async def npc_attack(player_id: int) -> Tuple[str, bool]: """ NPC attacks the player. Returns: (message, player_died) """ combat = await database.get_combat(player_id) if not combat or combat['turn'] != 'npc': return ("", False) player = await database.get_player(player_id) npc_def = NPCS.get(combat['npc_id']) if not player or not npc_def: return ("Combat error!", False) # Check if NPC is stunned npc_effects = json.loads(combat['npc_status_effects']) is_stunned = any(effect['name'] == 'Stunned' for effect in npc_effects) if is_stunned: # Update status effects npc_effects = update_status_effects(npc_effects) await database.update_combat(player_id, { 'turn': 'player', 'turn_started_at': time.time(), 'npc_status_effects': json.dumps(npc_effects) }) return (f"āš ļø The {npc_def.name} is stunned and cannot attack!", False) # Calculate damage damage = calculate_npc_damage(npc_def, combat['npc_hp'], combat['npc_max_hp']) # Apply damage to player new_player_hp = max(0, player['hp'] - damage) await database.update_player(player_id, {'hp': new_player_hp}) message = f"šŸ’„ The {npc_def.name} attacks you for {damage} damage!" # Check for status effect infliction player_effects = json.loads(combat['player_status_effects']) if random.random() < npc_def.status_inflict_chance: # Bleeding is most common player_effects.append({ 'name': 'Bleeding', 'turns_remaining': 3, 'damage_per_turn': 2 }) message += "\n🩸 You're bleeding!" # Apply status effect damage to NPC npc_effects, status_damage, status_messages = apply_status_effects(npc_effects) if status_damage > 0: new_npc_hp = max(0, combat['npc_hp'] - status_damage) await database.update_combat(player_id, {'npc_hp': new_npc_hp}) message += f"\n{status_messages}" if new_npc_hp <= 0: victory_msg = await handle_npc_death(player_id, combat, npc_def) return (message + "\n\n" + victory_msg, False) # Check if player died if new_player_hp <= 0: await handle_player_death(player_id) return (message + "\n\nšŸ’€ You have been slain...", True) # Update combat - switch to player turn await database.update_combat(player_id, { 'turn': 'player', 'turn_started_at': time.time(), 'player_status_effects': json.dumps(player_effects), 'npc_status_effects': json.dumps(npc_effects) }) message += f"\nā¤ļø Your HP: {new_player_hp}/{player['max_hp']}" message += f"\n{npc_def.emoji} {npc_def.name}: {combat['npc_hp']}/{combat['npc_max_hp']} HP" return (message, False) async def flee_attempt(player_id: int) -> Tuple[str, bool, bool]: """ Player attempts to flee from combat. Returns: (message, fled_successfully, turn_ended) """ combat = await database.get_combat(player_id) if not combat or combat['turn'] != 'player': return ("It's not your turn!", False, False) player = await database.get_player(player_id) npc_def = NPCS.get(combat['npc_id']) # Base flee chance is 50%, modified by agility flee_chance = 0.5 + (player['agility'] / 100) if random.random() < flee_chance: # Success! Check if we need to respawn the wandering enemy if combat.get('from_wandering_enemy', False): # Respawn the enemy at the same location await database.spawn_wandering_enemy( npc_id=combat['npc_id'], location_id=combat['location_id'], lifetime_seconds=600 # 10 minutes ) await database.end_combat(player_id) return (f"šŸƒ You successfully flee from the {npc_def.name}!", True, True) else: # Failed - lose turn and NPC attacks message = f"āŒ You failed to escape! The {npc_def.name} takes advantage!" # NPC gets a free attack await database.update_combat(player_id, { 'turn': 'npc', 'turn_started_at': time.time() }) return (message, False, True) def update_status_effects(effects: List[Dict]) -> List[Dict]: """Decrease turn counters on status effects.""" new_effects = [] for effect in effects: effect['turns_remaining'] -= 1 if effect['turns_remaining'] > 0: new_effects.append(effect) return new_effects def apply_status_effects(effects: List[Dict]) -> Tuple[List[Dict], int, str]: """ Apply status effect damage. Returns: (updated_effects, total_damage, message) """ total_damage = 0 messages = [] for effect in effects: if effect['damage_per_turn'] > 0: total_damage += effect['damage_per_turn'] if effect['name'] == 'Bleeding': messages.append(f"🩸 Bleeding: -{effect['damage_per_turn']} HP") elif effect['name'] == 'Infected': messages.append(f"🦠 Infection: -{effect['damage_per_turn']} HP") return effects, total_damage, "\n".join(messages) async def handle_npc_death(player_id: int, combat: Dict, npc_def) -> str: """Handle NPC death - give XP, drop loot, create corpse.""" player = await database.get_player(player_id) # Give XP new_xp = player['xp'] + npc_def.xp_reward level_up_msg = "" # Check for level up current_level = player['level'] xp_needed = xp_for_level(current_level + 1) if new_xp >= xp_needed: new_level = current_level + 1 # Give stat points instead of auto-allocating # Players get 5 points per level to spend as they wish points_gained = 5 new_unspent_points = player.get('unspent_points', 0) + points_gained await database.update_player(player_id, { 'xp': new_xp, 'level': new_level, 'hp': player['max_hp'], # Heal on level up 'stamina': player['max_stamina'], # Restore stamina on level up 'unspent_points': new_unspent_points }) level_up_msg = f"\n\nšŸŽ‰ LEVEL UP! You are now level {new_level}!" level_up_msg += f"\n⭐ You have {points_gained} stat points to spend!" level_up_msg += f"\nā¤ļø Fully healed and stamina restored!" level_up_msg += f"\n\nšŸ’” Check your profile to spend your points!" else: await database.update_player(player_id, {'xp': new_xp}) # Drop loot loot_msg = "\n\nšŸ’° Loot dropped:" loot_items = [] for loot_item in npc_def.loot_table: if random.random() < loot_item.drop_chance: quantity = random.randint(loot_item.quantity_min, loot_item.quantity_max) await database.drop_item_to_world( loot_item.item_id, quantity, combat['location_id'] ) item_def = ITEMS.get(loot_item.item_id, {}) loot_msg += f"\n{item_def.get('emoji', 'ā”')} {item_def.get('name', 'Unknown')} x{quantity}" loot_items.append(loot_item.item_id) if not loot_items: loot_msg += "\nNothing..." # Create corpse if it has corpse loot if npc_def.corpse_loot: corpse_loot_json = json.dumps([{ 'item_id': cl.item_id, 'quantity_min': cl.quantity_min, 'quantity_max': cl.quantity_max, 'required_tool': cl.required_tool } for cl in npc_def.corpse_loot]) await database.create_npc_corpse( npc_id=combat['npc_id'], location_id=combat['location_id'], loot_remaining=corpse_loot_json ) loot_msg += f"\n\n{npc_def.emoji} The corpse can be scavenged for more resources." # End combat await database.end_combat(player_id) message = f"šŸ† Victory! {npc_def.death_message}" message += f"\n+{npc_def.xp_reward} XP" message += level_up_msg message += loot_msg return message async def handle_player_death(player_id: int): """Handle player death - create corpse bag with all items.""" player = await database.get_player(player_id) inventory_items = await database.get_inventory(player_id) # Check if combat was with a wandering enemy that should respawn combat = await database.get_combat(player_id) if combat and combat.get('from_wandering_enemy', False): # Respawn the enemy at the same location await database.spawn_wandering_enemy( npc_id=combat['npc_id'], location_id=combat['location_id'], lifetime_seconds=600 # 10 minutes ) # Create corpse bag if player has items if inventory_items: items_json = json.dumps([{ 'item_id': item['item_id'], 'quantity': item['quantity'] } for item in inventory_items]) await database.create_player_corpse( player_name=player['name'], location_id=player['location_id'], items=items_json ) # Remove all items from player for item in inventory_items: await database.remove_item_from_inventory(item['id'], item['quantity']) # Mark player as dead and end any combat await database.update_player(player_id, {'is_dead': True, 'hp': 0}) await database.end_combat(player_id) async def use_item_in_combat(player_id: int, item_db_id: int) -> Tuple[str, bool]: """ Use a consumable item during combat. Returns: (message, turn_ended) """ combat = await database.get_combat(player_id) if not combat or combat['turn'] != 'player': return ("It's not your turn!", False) item_data = await database.get_inventory_item(item_db_id) if not item_data or item_data['player_id'] != player_id: return ("You don't have that item!", False) item_def = ITEMS.get(item_data['item_id']) if not item_def or item_def.get('type') != 'consumable': return ("That item cannot be used in combat!", False) player = await database.get_player(player_id) # Apply consumable effects message = f"šŸ’Š Used {item_def['name']}!" hp_restore = item_def.get('hp_restore', 0) stamina_restore = item_def.get('stamina_restore', 0) updates = {} if hp_restore > 0: new_hp = min(player['hp'] + hp_restore, player['max_hp']) updates['hp'] = new_hp message += f"\nā¤ļø +{hp_restore} HP" if stamina_restore > 0: new_stamina = min(player['stamina'] + stamina_restore, player['max_stamina']) updates['stamina'] = new_stamina message += f"\n⚔ +{stamina_restore} Stamina" if updates: await database.update_player(player_id, updates) # Remove item from inventory if item_data['quantity'] > 1: await database.update_inventory_item(item_db_id, item_data['quantity'] - 1) else: await database.remove_item_from_inventory(item_db_id, 1) # Using an item ends your turn await database.update_combat(player_id, { 'turn': 'npc', 'turn_started_at': time.time() }) return (message, True)