""" Standalone game logic for the API. Contains all game mechanics without bot dependencies. """ import random import time from typing import Dict, Any, Tuple, Optional, List from . import database as db from .services.helpers import get_locale_string, translate_travel_message, create_combat_message, get_game_message async def move_player(player_id: int, direction: str, locations: Dict, locale: str = 'en') -> Tuple[bool, str, Optional[str], int, int]: """ Move player in a direction. Returns: (success, message, new_location_id, stamina_cost, distance_meters) """ player = await db.get_character_by_id(player_id) if not player: return False, "Player not found", None, 0, 0 current_location_id = player['location_id'] current_location = locations.get(current_location_id) if not current_location: return False, "Current location not found", None, 0, 0 # Check if direction is valid if direction not in current_location.exits: return False, f"You cannot go {direction} from here.", None, 0, 0 new_location_id = current_location.exits[direction] new_location = locations.get(new_location_id) if not new_location: return False, "Destination not found", None, 0, 0 # Calculate total weight and capacity from api.items import items_manager as ITEMS_MANAGER from api.services.helpers import calculate_player_capacity inventory = await db.get_inventory(player_id) current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER) # Calculate distance between locations (1 coordinate unit = 100 meters) import math coord_distance = math.sqrt( (new_location.x - current_location.x)**2 + (new_location.y - current_location.y)**2 ) distance = int(coord_distance * 100) # Convert to meters, round to integer # Calculate stamina cost: base from distance, adjusted by weight and agility base_cost = max(1, round(distance / 50)) # 50m = 1 stamina weight_penalty = int(current_weight / 10) agility_reduction = int(player.get('agility', 5) / 3) # Add over-capacity penalty (50% extra stamina cost if over limit) over_capacity_penalty = 0 if current_weight > max_weight or current_volume > max_volume: weight_excess_ratio = max(0, (current_weight - max_weight) / max_weight) if max_weight > 0 else 0 volume_excess_ratio = max(0, (current_volume - max_volume) / max_volume) if max_volume > 0 else 0 excess_ratio = max(weight_excess_ratio, volume_excess_ratio) # Penalty scales from 50% to 200% based on how much over capacity over_capacity_penalty = int((base_cost + weight_penalty) * (0.5 + min(1.5, excess_ratio))) stamina_cost = max(1, base_cost + weight_penalty + over_capacity_penalty - agility_reduction) # Check stamina if player['stamina'] < stamina_cost: return False, get_game_message('exhausted_move', locale), None, 0, 0 # Update player location and stamina await db.update_character( player_id, location_id=new_location_id, stamina=max(0, player['stamina'] - stamina_cost) ) translated_location = get_locale_string(new_location.name, locale) travel_message = translate_travel_message(direction, translated_location, locale) return True, travel_message, new_location_id, stamina_cost, distance async def inspect_area(player_id: int, location, interactables_data: Dict, locale: str = 'en') -> str: """ Inspect the current area and return detailed information. Returns formatted text with interactables and their actions. """ player = await db.get_player_by_id(player_id) if not player: return "Player not found" # Check if player has enough stamina if player['stamina'] < 1: return get_game_message('exhausted_inspect', locale) # Deduct stamina await db.update_player_stamina(player_id, player['stamina'] - 1) # Build inspection message lines = [get_game_message('inspecting_title', locale, name=location.name)] lines.append(location.description) lines.append("") if location.interactables: lines.append(get_game_message('interactables_title', locale)) for interactable in location.interactables: lines.append(f"• **{interactable.name}**") if interactable.actions: actions_text = ", ".join([f"{action.label} (⚡{action.stamina_cost})" for action in interactable.actions]) lines.append(f" Actions: {actions_text}") lines.append("") if location.npcs: lines.append(f"{get_game_message('npcs_title', locale)} {', '.join(location.npcs)}") lines.append("") # Check for dropped items dropped_items = await db.get_dropped_items(location.id) if dropped_items: lines.append(get_game_message('items_ground_title', locale)) for item in dropped_items: lines.append(f"• {item['item_id']} x{item['quantity']}") return "\n".join(lines) async def interact_with_object( player_id: int, interactable_id: str, action_id: str, location, items_manager, locale: str = 'en' ) -> Dict[str, Any]: """ Interact with an object using a specific action. Returns: {success, message, items_found, damage_taken, stamina_cost} """ player = await db.get_player_by_id(player_id) if not player: return {"success": False, "message": "Player not found"} # Find the interactable (match by id or instance_id) interactable = None for obj in location.interactables: if obj.id == interactable_id or (hasattr(obj, 'instance_id') and obj.instance_id == interactable_id): interactable = obj break if not interactable: return {"success": False, "message": get_game_message('object_not_found', locale)} # Find the action action = None for act in interactable.actions: if act.id == action_id: action = act break if not action: return {"success": False, "message": get_game_message('action_not_found', locale)} # Check stamina if player['stamina'] < action.stamina_cost: return { "success": False, "message": get_game_message('not_enough_stamina', locale, cost=action.stamina_cost, current=player['stamina']) } # Check cooldown for this specific action cooldown_expiry = await db.get_interactable_cooldown(interactable_id, action_id) if cooldown_expiry: remaining = int(cooldown_expiry - time.time()) return { "success": False, "message": get_game_message('cooldown_wait', locale, seconds=remaining) } # Deduct stamina new_stamina = player['stamina'] - action.stamina_cost await db.update_player_stamina(player_id, new_stamina) # Determine outcome (simple success/failure for now) # TODO: Implement proper skill checks roll = random.randint(1, 100) if roll <= 10: # 10% critical failure outcome_key = 'critical_failure' elif roll <= 30: # 20% failure outcome_key = 'failure' else: # 70% success outcome_key = 'success' outcome = action.outcomes.get(outcome_key) if not outcome: # Fallback to success if outcome not defined outcome = action.outcomes.get('success') if not outcome: return { "success": False, "message": get_game_message('action_no_outcomes', locale) } # Process outcome items_found = [] items_dropped = [] damage_taken = outcome.damage_taken # Calculate current capacity and fetch derived stats from api.services.helpers import calculate_player_capacity from api.services.stats import calculate_derived_stats from api.items import items_manager as ITEMS_MANAGER inventory = await db.get_inventory(player_id) current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER) stats = await calculate_derived_stats(player_id) loot_quality = stats.get('loot_quality', 1.0) # Add items to inventory (or drop if over capacity) for item_id, quantity in outcome.items_reward.items(): item = items_manager.get_item(item_id) if not item: continue item_name = get_locale_string(item.name, locale) if item else item_id emoji = item.emoji if item and hasattr(item, 'emoji') else '' # Check if item has durability (unique item) has_durability = hasattr(item, 'durability') and item.durability is not None # For items with durability, we need to create each one individually if has_durability: for _ in range(quantity): # Check if item fits in inventory if (current_weight + item.weight <= max_weight and current_volume + item.volume <= max_volume): # Add to inventory with durability properties await db.add_item_to_inventory( player_id, item_id, quantity=1, durability=item.durability, max_durability=item.durability, tier=getattr(item, 'tier', None) ) items_found.append(f"{emoji} {item_name}") current_weight += item.weight current_volume += item.volume else: # Create unique_item and drop to ground # Save base stats to unique_stats base_stats = {k: int(v) if isinstance(v, (int, float)) else v for k, v in item.stats.items()} if item.stats else {} unique_item_id = await db.create_unique_item( item_id=item_id, durability=item.durability, max_durability=item.durability, tier=getattr(item, 'tier', None), unique_stats=base_stats ) await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id) items_dropped.append(f"{emoji} {item_name}") else: # Stackable items - apply loot quality bonus for resources and consumables if getattr(item, 'category', item.type) in ['resource', 'consumable'] and loot_quality > 1.0: bonus_chance = loot_quality - 1.0 if random.random() < bonus_chance: quantity += 1 item_weight = item.weight * quantity item_volume = item.volume * quantity if (current_weight + item_weight <= max_weight and current_volume + item_volume <= max_volume): # Add to inventory await db.add_item_to_inventory(player_id, item_id, quantity) items_found.append(f"{emoji} {item_name} x{quantity}") current_weight += item_weight current_volume += item_volume else: # Drop to ground await db.drop_item_to_world(item_id, quantity, player['location_id']) items_dropped.append(f"{emoji} {item_name} x{quantity}") # Apply damage if damage_taken > 0: new_hp = max(0, player['hp'] - damage_taken) await db.update_player_hp(player_id, new_hp) # Check if player died if new_hp <= 0: await db.update_player(player_id, is_dead=True) # Set cooldown for this specific action (60 seconds default) await db.set_interactable_cooldown(interactable_id, action_id, 60) # Build message final_message = get_locale_string(outcome.text, locale) if items_dropped: final_message += f"\n⚠️ {get_game_message('inventory_full', locale)}! {get_game_message('dropped_to_ground', locale)}: {', '.join(items_dropped)}" return { "success": True, "message": final_message, "items_found": items_found, "items_dropped": items_dropped, "damage_taken": damage_taken, "stamina_cost": action.stamina_cost, "new_stamina": new_stamina, "new_hp": player['hp'] - damage_taken if damage_taken > 0 else player['hp'] } async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'en') -> Dict[str, Any]: """ Use an item from inventory. Returns: {success, message, effects} """ player = await db.get_player_by_id(player_id) if not player: return {"success": False, "message": "Player not found"} # Get derived stats for item effectiveness # In some paths redis_manager might not be injected, so we attempt to fetch it from websockets module if needed, # or let stats service fetch without cache from api.services.stats import calculate_derived_stats import api.core.websockets as ws redis_mgr = getattr(ws.manager, 'redis_manager', None) stats = await calculate_derived_stats(player['id'], redis_mgr) item_effectiveness = stats.get('item_effectiveness', 1.0) # Check if player has the item inventory = await db.get_inventory(player_id) item_entry = None for inv_item in inventory: if inv_item['item_id'] == item_id: item_entry = inv_item break if not item_entry: return {"success": False, "message": get_game_message('no_item', locale)} # Get item data item = items_manager.get_item(item_id) if not item: return {"success": False, "message": "Item not found in game data"} if not item.consumable: return {"success": False, "message": get_game_message('cannot_use', locale)} # Apply item effects effects = {} effects_msg = [] # 1. Apply Status Effects (e.g. Regeneration from Bandage) if 'status_effect' in item.effects: status_data = item.effects['status_effect'] # Check if effect already exists current_effects = await db.get_player_effects(player_id) effect_name = status_data['name'] # Handle potential dict/string difference in validation (db stores as string usually) # But we need to compare with what's in the DB. # DB get_player_effects returns list of dicts with 'effect_name' key. is_active = False for effect in current_effects: # Simple string comparison should suffice as both should be localized keys or raw strings if effect['effect_name'] == effect_name: is_active = True break if is_active: return {"success": False, "message": get_game_message('effect_already_active', locale)} await db.add_effect( player_id=player['id'], effect_name=status_data['name'], effect_icon=status_data.get('icon', '✨'), effect_type=status_data.get('type', 'buff'), damage_per_tick=status_data.get('damage_per_tick', 0), value=status_data.get('value', 0), ticks_remaining=status_data.get('ticks', 3), persist_after_combat=True, # Consumable effects usually persist source=f"item:{item.id}" ) effects['status_applied'] = status_data['name'] effects_msg.append(f"Applied {get_locale_string(status_data['name'], locale) if isinstance(status_data['name'], dict) else status_data['name']}") # 2. Cure Status Effects if 'cures' in item.effects: cures = item.effects['cures'] cured_list = [] for cure_effect in cures: if await db.remove_effect(player['id'], cure_effect): cured_list.append(cure_effect) if cured_list: effects['cured'] = cured_list effects_msg.append(f"{get_game_message('cured', locale)}: {', '.join(cured_list)}") # 3. Direct Healing (Legacy/Instant) if 'hp_restore' in item.effects: base_hp_restore = item.effects['hp_restore'] hp_restore = int(base_hp_restore * item_effectiveness) old_hp = player['hp'] new_hp = min(player['max_hp'], old_hp + hp_restore) actual_restored = new_hp - old_hp if actual_restored > 0: await db.update_player_hp(player_id, new_hp) effects['hp_restored'] = actual_restored effects_msg.append(f"+{actual_restored} HP") if 'stamina_restore' in item.effects: base_stamina_restore = item.effects['stamina_restore'] stamina_restore = int(base_stamina_restore * item_effectiveness) old_stamina = player['stamina'] new_stamina = min(player['max_stamina'], old_stamina + stamina_restore) actual_restored = new_stamina - old_stamina if actual_restored > 0: await db.update_player_stamina(player_id, new_stamina) effects['stamina_restored'] = actual_restored effects_msg.append(f"+{actual_restored} Stamina") # Consume the item (remove 1 from inventory) await db.remove_item_from_inventory(player_id, item_id, 1) # Track statistics stat_updates = {"items_used": 1, "increment": True} if 'hp_restored' in effects: stat_updates['hp_restored'] = effects['hp_restored'] if 'stamina_restored' in effects: stat_updates['stamina_restored'] = effects['stamina_restored'] await db.update_player_statistics(player_id, **stat_updates) # Build message msg = f"{get_game_message('item_used', locale, name=get_locale_string(item.name, locale))}" if effects_msg: msg += f" ({', '.join(effects_msg)})" return { "success": True, "message": msg, "effects": effects } async def pickup_item(player_id: int, item_id: int, location_id: str, quantity: int = None, items_manager=None, locale: str = 'en') -> Dict[str, Any]: """ Pick up an item from the ground. item_id is the dropped_item id, not the item_id field. quantity: how many to pick up (None = all) items_manager: ItemsManager instance to get item definitions Returns: {success, message} """ # Get the dropped item by its ID dropped_item = await db.get_dropped_item(item_id) if not dropped_item: return {"success": False, "message": get_game_message('item_not_found_ground', locale)} # Get item definition item_def = items_manager.get_item(dropped_item['item_id']) if items_manager else None if not item_def: return {"success": False, "message": "Item data not found"} # Determine how many to pick up available_qty = dropped_item['quantity'] if quantity is None or quantity >= available_qty: pickup_qty = available_qty else: if quantity < 1: return {"success": False, "message": get_game_message('invalid_quantity', locale)} pickup_qty = quantity # Get player and calculate capacity from api.services.helpers import calculate_player_capacity player = await db.get_player_by_id(player_id) inventory = await db.get_inventory(player_id) # Calculate current weight and volume (including equipped bag capacity) current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, items_manager) # Calculate weight and volume for items to pick up item_weight = item_def.weight * pickup_qty item_volume = item_def.volume * pickup_qty new_weight = current_weight + item_weight new_volume = current_volume + item_volume # Check limits if new_weight > max_weight: return { "success": False, "message": get_game_message('item_too_heavy', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=pickup_qty, weight=item_weight, current=current_weight, max=max_weight) } if new_volume > max_volume: return { "success": False, "message": get_game_message('item_too_large', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=pickup_qty, volume=item_volume, current=current_volume, max=max_volume) } # Items fit - update dropped item quantity or remove it if pickup_qty >= available_qty: await db.remove_dropped_item(item_id) else: new_qty = available_qty - pickup_qty await db.update_dropped_item_quantity(item_id, new_qty) # Add to inventory (pass unique_item_id if it's a unique item) await db.add_item_to_inventory( player_id, dropped_item['item_id'], pickup_qty, unique_item_id=dropped_item.get('unique_item_id') ) return { "success": True, "message": f"{get_game_message('picked_up', locale)} {item_def.emoji} {get_locale_string(item_def.name, locale)} x{pickup_qty}" } async def check_and_apply_level_up(player_id: int) -> Dict[str, Any]: """ Check if player has enough XP to level up and apply it. Returns: {leveled_up: bool, new_level: int, levels_gained: int} """ player = await db.get_player_by_id(player_id) if not player: return {"leveled_up": False, "new_level": 1, "levels_gained": 0} current_level = player['level'] current_xp = player['xp'] levels_gained = 0 # Check for level ups (can level up multiple times if enough XP) while current_xp >= (current_level * 100): current_xp -= (current_level * 100) current_level += 1 levels_gained += 1 if levels_gained > 0: # Update player with new level, remaining XP, and unspent points new_unspent_points = player['unspent_points'] + levels_gained await db.update_player( player_id, level=current_level, xp=current_xp, unspent_points=new_unspent_points ) # Invalidate cached derived stats (level affects max_hp, max_stamina, attack_power, etc.) from api.services.stats import invalidate_stats_cache try: from api.core.websockets import manager as ws_manager await invalidate_stats_cache(player_id, getattr(ws_manager, 'redis_manager', None)) except Exception: pass return { "leveled_up": True, "new_level": current_level, "levels_gained": levels_gained } return {"leveled_up": False, "new_level": current_level, "levels_gained": 0} # ============================================================================ # STATUS EFFECTS UTILITIES # ============================================================================ def calculate_status_impact(effects: list) -> int: """ Calculate total impact from all status effects. Positive value = Damage Negative value = Healing Args: effects: List of status effect dicts Returns: Total impact per tick """ return sum(effect.get('damage_per_tick', 0) for effect in effects) # ============================================================================ # COMBAT UTILITIES # ============================================================================ def generate_npc_intent(npc_def, combat_state: dict) -> dict: """ Generate the NEXT intent for an NPC. Returns a dict with intent type and details. """ import random from api.services.skills import skills_manager npc_hp_pct = combat_state['npc_hp'] / combat_state['npc_max_hp'] if combat_state['npc_max_hp'] > 0 else 0 skills = getattr(npc_def, 'skills', []) active_effects = combat_state.get('npc_status_effects', '') cooldowns = {} if active_effects: for eff in active_effects.split('|'): if eff.startswith('cd_'): parts = eff.split(':') if len(parts) >= 2: cooldowns[parts[0][3:]] = int(parts[1]) available_skills = [] has_heal = None has_buff = None damage_skills = [] for skill_id in skills: if cooldowns.get(skill_id, 0) > 0: continue skill = skills_manager.get_skill(skill_id) if not skill: continue available_skills.append(skill) if 'heal_percent' in skill.effects: has_heal = skill elif 'buff' in skill.effects: has_buff = skill else: damage_skills.append(skill) # 1. Survival First if has_heal and npc_hp_pct < 0.3: if random.random() < 0.8: return {"type": "skill", "value": has_heal.id} # 2. Buffs if has_buff: buff_name = has_buff.effects['buff'] is_buff_active = False if active_effects: for eff in active_effects.split('|'): if eff.startswith(buff_name + ':'): is_buff_active = True break if not is_buff_active and random.random() < 0.6: return {"type": "skill", "value": has_buff.id} # 3. Telegraphed Attack Check (15% chance if health > 30%) if npc_hp_pct > 0.3 and random.random() < 0.15: return {"type": "charge", "value": "charging_attack"} # 4. Damage Skills if damage_skills and random.random() < 0.4: chosen = random.choice(damage_skills) return {"type": "skill", "value": chosen.id} # Default to attack or defend (legacy logic) roll = random.random() if npc_hp_pct < 0.5 and roll < 0.1: return {"type": "defend", "value": 0} return {"type": "attack", "value": 0} async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, player_stats: dict = None, locale: str = 'en') -> Tuple[List[dict], bool]: """ Execute NPC turn based on PREVIOUS intent, then generate NEXT intent. Returns: (messages_list, player_defeated) """ import random import time from api import database as db from api.services.helpers import create_combat_message, get_game_message, get_locale_string from api.services.skills import skills_manager player = await db.get_player_by_id(player_id) if not player: return [], True messages = [] # 1. PROCESS NPC STATUS EFFECTS npc_hp = combat['npc_hp'] npc_max_hp = combat['npc_max_hp'] npc_status_str = combat.get('npc_status_effects', '') is_stunned = False if npc_status_str: effects_list = npc_status_str.split('|') active_effects = [] npc_damage_taken = 0 npc_healing_received = 0 for effect_str in effects_list: if not effect_str: continue try: parts = effect_str.split(':') name = parts[0] if name == 'stun' and len(parts) >= 2: ticks = int(parts[1]) if ticks > 0: is_stunned = True messages.append(create_combat_message( "skill_effect", origin="enemy", message=get_game_message('npc_stunned_cannot_act', locale, npc_name=get_locale_string(npc_def.name, locale)) )) ticks -= 1 if ticks > 0: active_effects.append(f"stun:{ticks}") continue if name.startswith('cd_') and len(parts) >= 3: ticks = int(parts[2]) ticks -= 1 if ticks > 0: active_effects.append(f"{name}:{parts[1]}:{ticks}") continue if len(parts) >= 3: dmg = int(parts[1]) ticks = int(parts[2]) if ticks > 0: if dmg > 0: npc_damage_taken += dmg messages.append(create_combat_message( "effect_damage", origin="enemy", damage=dmg, effect_name=name, npc_name=npc_def.name )) elif dmg < 0: heal = abs(dmg) npc_healing_received += heal messages.append(create_combat_message( "effect_heal", origin="enemy", heal=heal, effect_name=name, npc_name=npc_def.name )) elif name in ["berserker_rage", "fortify", "analyzed"]: pass ticks -= 1 if ticks > 0: active_effects.append(f"{name}:{dmg}:{ticks}") except Exception as e: print(f"Error parsing NPC status: {e}") new_status_str = "|".join(active_effects) if new_status_str != npc_status_str: await db.update_combat(player_id, {'npc_status_effects': new_status_str}) if npc_damage_taken > 0: npc_hp = max(0, npc_hp - npc_damage_taken) if npc_healing_received > 0: npc_hp = min(npc_max_hp, npc_hp + npc_healing_received) await db.update_combat(player_id, {'npc_hp': npc_hp}) if npc_hp <= 0: messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name)) return messages, False current_intent_str = combat.get('npc_intent', 'attack') if not current_intent_str: current_intent_str = 'attack' intent_parts = current_intent_str.split(':') intent_type = intent_parts[0] intent_value = intent_parts[1] if len(intent_parts) > 1 else None actual_damage = 0 new_player_hp = player['hp'] if npc_hp > 0 and not is_stunned: if intent_type == 'defend': heal_amount = int(combat['npc_max_hp'] * 0.05) new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount) await db.update_combat(player_id, {'npc_hp': new_npc_hp}) messages.append(create_combat_message("enemy_defend", origin="enemy", npc_name=npc_def.name, heal=heal_amount)) elif intent_type == 'charge': messages.append(create_combat_message( "skill_effect", origin="enemy", message=get_game_message('enemy_charging', locale, enemy=get_locale_string(npc_def.name, locale)) )) elif intent_type in ('charging_attack', 'special', 'attack', 'skill'): npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) skill = None is_charging = intent_type == 'charging_attack' if intent_type == 'charging_attack': npc_damage = int(npc_damage * 2.5) elif intent_type == 'special': npc_damage = int(npc_damage * 1.5) elif intent_type == 'skill' and intent_value: skill = skills_manager.get_skill(intent_value) if skill: if skill.cooldown > 0: cd_str = f"cd_{skill.id}:0:{skill.cooldown}" curr_combat = await db.get_active_combat(player_id) curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else '' new_status = curr_status + f"|{cd_str}" if curr_status else cd_str await db.update_combat(player_id, {'npc_status_effects': new_status}) effects = skill.effects if 'heal_percent' in effects: heal_amount = int(combat['npc_max_hp'] * effects['heal_percent']) new_npc_hp = min(combat['npc_max_hp'], npc_hp + heal_amount) await db.update_combat(player_id, {'npc_hp': new_npc_hp}) messages.append(create_combat_message("skill_heal", origin="enemy", heal=heal_amount, skill_icon=skill.icon, skill_name=get_locale_string(skill.name, locale), npc_name=npc_def.name)) npc_damage = 0 if 'buff' in effects: buff_str = f"{effects['buff']}:0:{effects['buff_duration']}" curr_combat = await db.get_active_combat(player_id) curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else '' new_status = curr_status + f"|{buff_str}" if curr_status else buff_str await db.update_combat(player_id, {'npc_status_effects': new_status}) messages.append(create_combat_message("skill_buff", origin="enemy", skill_name=get_locale_string(skill.name, locale), skill_icon=skill.icon, duration=effects['buff_duration'], npc_name=npc_def.name)) if 'damage_multiplier' not in effects and 'poison_damage' not in effects: npc_damage = 0 if 'damage_multiplier' in effects: npc_damage = max(1, int(npc_damage * effects['damage_multiplier'])) from api.services.helpers import calculate_dynamic_status_damage poison_dmg = calculate_dynamic_status_damage(effects, 'poison', player) if poison_dmg is not None: await db.add_effect(player_id=player_id, effect_name="Poison", effect_icon="🧪", effect_type="damage", damage_per_tick=poison_dmg, ticks_remaining=effects.get('poison_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}") burn_dmg = calculate_dynamic_status_damage(effects, 'burn', player) if burn_dmg is not None: await db.add_effect(player_id=player_id, effect_name="Burning", effect_icon="🔥", effect_type="damage", damage_per_tick=burn_dmg, ticks_remaining=effects.get('burn_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}") is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3 if is_enraged and npc_damage > 0: npc_damage = int(npc_damage * 1.5) messages.append(create_combat_message("enemy_enraged", origin="enemy", npc_name=npc_def.name)) curr_combat = await db.get_active_combat(player_id) curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else '' if 'berserker_rage' in curr_status and npc_damage > 0: npc_damage = int(npc_damage * 1.5) if npc_damage > 0: dodged = False is_defending = False player_effects = await db.get_player_effects(player_id) defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None) if defending_effect: is_defending = True reduction = defending_effect.get('value', 50) / 100 npc_damage = max(1, int(npc_damage * (1 - reduction))) messages.append(create_combat_message("damage_reduced", origin="player", reduction=int(reduction * 100))) await db.remove_effect(player_id, 'defending') buff_dmg_reduction = player_stats.get('buff_damage_reduction', 0.0) if player_stats else 0.0 if buff_dmg_reduction > 0: npc_damage = max(1, int(npc_damage * (1 - buff_dmg_reduction))) messages.append(create_combat_message("damage_reduced", origin="player", reduction=int(buff_dmg_reduction * 100))) buff_dmg_taken_increase = player_stats.get('buff_damage_taken_increase', 0.0) if player_stats else 0.0 if buff_dmg_taken_increase > 0: npc_damage = int(npc_damage * (1 + buff_dmg_taken_increase)) if player_stats and player_stats.get('buff_guaranteed_dodge', False): dodged = True messages.append(create_combat_message("combat_dodge", origin="player")) await db.remove_effect(player_id, 'evade') elif player_stats and player_stats.get('buff_enemy_miss', False): dodged = True messages.append(create_combat_message("combat_dodge", origin="player")) elif player_stats and 'dodge_chance' in player_stats and random.random() < player_stats['dodge_chance']: dodged = True messages.append(create_combat_message("combat_dodge", origin="player")) if not dodged and player_stats and player_stats.get('has_shield', False) and random.random() < player_stats.get('block_chance', 0): messages.append(create_combat_message("combat_block", origin="player")) npc_damage = max(1, int(npc_damage * 0.2)) if not dodged: armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage, is_defending) if player_stats and player_stats.get('armor_reduction', 0) > 0: pct_reduction = player_stats['armor_reduction'] actual_damage = max(1, int(npc_damage * (1 - pct_reduction))) armor_absorbed_visual = npc_damage - actual_damage else: actual_damage = max(1, npc_damage - armor_absorbed) armor_absorbed_visual = armor_absorbed new_player_hp = max(0, player['hp'] - actual_damage) if skill and 'damage_multiplier' in skill.effects: messages.append(create_combat_message("skill_attack", origin="enemy", damage=actual_damage, skill_name=get_locale_string(skill.name, locale), skill_icon=skill.icon, hits=1)) elif is_charging: messages.append(create_combat_message("enemy_special", origin="enemy", npc_name=npc_def.name, damage=actual_damage, armor_absorbed=armor_absorbed_visual)) else: messages.append(create_combat_message("enemy_attack", origin="enemy", npc_name=npc_def.name, damage=actual_damage, armor_absorbed=armor_absorbed_visual)) if broken_armor: for armor in broken_armor: messages.append(create_combat_message("item_broken", origin="player", item_name=armor['name'], emoji=armor['emoji'])) await db.update_player(player_id, hp=new_player_hp) player_defeated = False if new_player_hp <= 0 and intent_type != 'defend' and intent_type != 'charge': messages.append(create_combat_message("player_defeated", origin="neutral", npc_name=npc_def.name)) player_defeated = True await db.update_player(player_id, hp=0, is_dead=True) await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True) await db.end_combat(player_id) return messages, player_defeated if actual_damage > 0: await db.update_player_statistics(player_id, damage_taken=actual_damage, increment=True) current_npc_hp = combat['npc_hp'] if intent_type == 'defend': current_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + int(combat['npc_max_hp'] * 0.05)) temp_combat_state = combat.copy() temp_combat_state['npc_hp'] = current_npc_hp if intent_type == 'charge': next_intent_str = 'charging_attack' else: next_intent = generate_npc_intent(npc_def, temp_combat_state) next_intent_str = f"{next_intent['type']}:{next_intent['value']}" if next_intent['type'] == 'skill' else next_intent['type'] await db.update_combat(player_id, { 'turn': 'player', 'turn_started_at': time.time(), 'npc_intent': next_intent_str }) return messages, player_defeated