""" Combat router. Auto-generated from main.py migration. """ from fastapi import APIRouter, HTTPException, Depends, status, Request from fastapi.security import HTTPAuthorizationCredentials from typing import Optional, Dict, Any from datetime import datetime import random import json import logging from ..services.constants import PVP_TURN_TIMEOUT from ..core.security import get_current_user, security, verify_internal_key from ..services.models import * from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message, get_game_message from .. import database as db from ..items import ItemsManager from .. import game_logic from ..core.websockets import manager from .equipment import reduce_armor_durability logger = logging.getLogger(__name__) # These will be injected by main.py LOCATIONS = None ITEMS_MANAGER = None WORLD = None redis_manager = None def init_router_dependencies(locations, items_manager, world, redis_mgr=None): """Initialize router with game data dependencies""" global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager LOCATIONS = locations ITEMS_MANAGER = items_manager WORLD = world redis_manager = redis_mgr router = APIRouter(tags=["combat"]) # Endpoints @router.get("/api/game/combat") async def get_combat_status(current_user: dict = Depends(get_current_user)): """Get current combat status""" combat = await db.get_active_combat(current_user['id']) if not combat: return {"in_combat": False} # Load NPC data from npcs.json import sys sys.path.insert(0, '/app') from data.npcs import NPCS npc_def = NPCS.get(combat['npc_id']) # Calculate time remaining for turn (server-side to avoid clock offset) import time turn_time_remaining = None if combat['turn'] == 'player': turn_started_at = combat.get('turn_started_at', 0) time_elapsed = time.time() - turn_started_at turn_time_remaining = max(0, 300 - time_elapsed) # 5 minutes = 300 seconds return { "in_combat": True, "combat": { "npc_id": combat['npc_id'], "npc_name": npc_def.name if npc_def else combat['npc_id'].replace('_', ' ').title(), "npc_hp": combat['npc_hp'], "npc_max_hp": combat['npc_max_hp'], "npc_image": f"{npc_def.image_path}" if npc_def else None, "turn": combat['turn'], "round": combat.get('round', 1), "turn_time_remaining": turn_time_remaining } } @router.post("/api/game/combat/initiate") async def initiate_combat( req: InitiateCombatRequest, request: Request, current_user: dict = Depends(get_current_user) ): """Start combat with a wandering enemy""" import random import sys sys.path.insert(0, '/app') from data.npcs import NPCS # Extract locale from Accept-Language header locale = request.headers.get('Accept-Language', 'en') # Check if already in combat existing_combat = await db.get_active_combat(current_user['id']) if existing_combat: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Already in combat" ) # Get enemy from wandering_enemies table async with db.DatabaseSession() as session: from sqlalchemy import select stmt = select(db.wandering_enemies).where(db.wandering_enemies.c.id == req.enemy_id) result = await session.execute(stmt) enemy = result.fetchone() if not enemy: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Enemy not found" ) # Get NPC definition npc_def = NPCS.get(enemy.npc_id) if not npc_def: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="NPC definition not found" ) # Randomize HP npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max) # Create combat combat = await db.create_combat( player_id=current_user['id'], npc_id=enemy.npc_id, npc_hp=npc_hp, npc_max_hp=npc_hp, location_id=current_user['location_id'], from_wandering=True ) # Remove the wandering enemy from the location async with db.DatabaseSession() as session: from sqlalchemy import delete stmt = delete(db.wandering_enemies).where(db.wandering_enemies.c.id == req.enemy_id) await session.execute(stmt) await session.commit() # Track combat initiation await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True) # Get player info for broadcasts player = current_user # current_user is already the character dict # Send WebSocket update to the player await manager.send_personal_message(current_user['id'], { "type": "combat_started", "data": { "messages": [create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name)], "combat": { "npc_id": enemy.npc_id, "npc_name": npc_def.name, "npc_hp": npc_hp, "npc_max_hp": npc_hp, "npc_image": f"{npc_def.image_path}", "turn": "player", "round": 1 } }, "timestamp": datetime.utcnow().isoformat() }) # Broadcast to location that player entered combat await manager.send_to_location( location_id=current_user['location_id'], message={ "type": "location_update", "data": { "message": get_game_message('player_entered_combat', locale, player_name=player['name'], npc_name=get_locale_string(npc_def.name, locale)), "action": "combat_started", "player_id": player['id'] }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=current_user['id'] ) return { "success": True, "messages": [create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name)], "combat": { "npc_id": enemy.npc_id, "npc_name": npc_def.name, "npc_hp": npc_hp, "npc_max_hp": npc_hp, "npc_image": f"{npc_def.image_path}", "turn": "player", "round": 1 } } @router.post("/api/game/combat/action") async def combat_action( req: CombatActionRequest, request: Request, current_user: dict = Depends(get_current_user) ): """Perform a combat action""" import random import sys sys.path.insert(0, '/app') from data.npcs import NPCS # Extract locale from Accept-Language header locale = request.headers.get('Accept-Language', 'en') # Get active combat combat = await db.get_active_combat(current_user['id']) if not combat: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Not in combat" ) if combat['turn'] != 'player': raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Not your turn" ) # Prevent rapid-fire attacks: Check if enough time has passed since last action # This prevents abuse by reloading the page to bypass the 2-second enemy turn delay # Skip this check on the first turn (round 1) since player always starts import time current_round = combat.get('round', 1) if current_round > 1: # Only check after first turn turn_started_at = combat.get('turn_started_at', 0) time_since_turn_start = time.time() - turn_started_at # If the turn just started (less than 2 seconds ago), it means the enemy just attacked # and we should wait for the animation to complete if time_since_turn_start < 2.0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Please wait for the enemy's turn to complete" ) # Get player and NPC data player = current_user # current_user is already the character dict npc_def = NPCS.get(combat['npc_id']) messages = [] combat_over = False player_won = False if req.action == 'attack': # Calculate player damage base_damage = 5 strength_bonus = player['strength'] // 2 level_bonus = player['level'] weapon_damage = 0 weapon_effects = {} weapon_inv_id = None # Check for equipped weapon equipment = await db.get_all_equipment(player['id']) if equipment.get('weapon') and equipment['weapon']: weapon_slot = equipment['weapon'] inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id']) if inv_item: weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id']) if weapon_def and weapon_def.stats: weapon_damage = random.randint( weapon_def.stats.get('damage_min', 0), weapon_def.stats.get('damage_max', 0) ) weapon_effects = weapon_def.weapon_effects if hasattr(weapon_def, 'weapon_effects') else {} weapon_inv_id = weapon_slot['item_id'] # Check encumbrance penalty (higher encumbrance = chance to miss) encumbrance = player.get('encumbrance', 0) attack_failed = False if encumbrance > 0: miss_chance = min(0.3, encumbrance * 0.05) # Max 30% miss chance if random.random() < miss_chance: attack_failed = True variance = random.randint(-2, 2) damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) if attack_failed: messages.append(create_combat_message( "player_miss", origin="player", reason="encumbrance" )) new_npc_hp = combat['npc_hp'] else: # Apply damage to NPC new_npc_hp = max(0, combat['npc_hp'] - damage) messages.append(create_combat_message( "player_attack", origin="player", damage=damage )) # Apply weapon effects if weapon_effects and 'bleeding' in weapon_effects: bleeding = weapon_effects['bleeding'] if random.random() < bleeding.get('chance', 0): # Apply bleeding effect (would need combat effects table, for now just bonus damage) bleed_damage = bleeding.get('damage', 0) new_npc_hp = max(0, new_npc_hp - bleed_damage) messages.append(create_combat_message( "effect_bleeding", origin="player", damage=bleed_damage )) # Decrease weapon durability (from unique_item) if weapon_inv_id and inv_item.get('unique_item_id'): new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1) if new_durability is None: # Weapon broke (unique_item was deleted, cascades to inventory) messages.append(create_combat_message( "weapon_broke", origin="player", item_name=weapon_def.name )) await db.unequip_item(player['id'], 'weapon') if new_npc_hp <= 0: # NPC defeated messages.append(create_combat_message( "victory", origin="neutral", npc_name=npc_def.name )) combat_over = True player_won = True # Award XP xp_gained = npc_def.xp_reward new_xp = player['xp'] + xp_gained messages.append(create_combat_message( "xp_gain", origin="player", amount=xp_gained )) await db.update_player(player['id'], xp=new_xp) # Track kill statistics await db.update_player_statistics(player['id'], enemies_killed=1, damage_dealt=damage, increment=True) # Check for level up level_up_result = await game_logic.check_and_apply_level_up(player['id']) if level_up_result['leveled_up']: messages.append(create_combat_message( "level_up", origin="player", level=level_up_result['new_level'], stat_points=level_up_result['levels_gained'] )) # Create corpse with loot import json corpse_loot = npc_def.corpse_loot if hasattr(npc_def, 'corpse_loot') else [] # Convert CorpseLoot objects to dicts corpse_loot_dicts = [] for loot in corpse_loot: if hasattr(loot, '__dict__'): corpse_loot_dicts.append({ 'item_id': loot.item_id, 'quantity_min': loot.quantity_min, 'quantity_max': loot.quantity_max, 'required_tool': loot.required_tool }) else: corpse_loot_dicts.append(loot) await db.create_npc_corpse( npc_id=combat['npc_id'], location_id=player['location_id'], loot_remaining=json.dumps(corpse_loot_dicts) ) await db.end_combat(player['id']) # Update Redis: Delete combat state cache if redis_manager: await redis_manager.delete_combat_state(player['id']) # Update player session await redis_manager.update_player_session_field(player['id'], 'xp', new_xp) if level_up_result['leveled_up']: await redis_manager.update_player_session_field(player['id'], 'level', level_up_result['new_level']) # Broadcast to location that combat ended and corpse appeared await manager.send_to_location( location_id=player['location_id'], message={ "type": "location_update", "data": { "message": get_game_message('player_defeated_enemy_broadcast', locale, player_name=player['name'], npc_name=get_locale_string(npc_def.name, locale)), "action": "combat_ended", "player_id": player['id'], "corpse_created": True }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] ) else: # NPC's turn - use shared logic npc_attack_messages, player_defeated = await game_logic.npc_attack( player['id'], {'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp']}, npc_def, reduce_armor_durability ) messages.extend(npc_attack_messages) if player_defeated: combat_over = True else: # Update NPC HP (combat turn already updated by npc_attack) await db.update_combat(player['id'], { 'npc_hp': new_npc_hp }) elif req.action == 'flee': # 50% chance to flee if random.random() < 0.5: messages.append(create_combat_message( "flee_success", origin="player", message=get_game_message('flee_success_text', locale, name=player['name']) )) combat_over = True player_won = False # Fled, not won # Track successful flee await db.update_player_statistics(player['id'], successful_flees=1, increment=True) # Respawn the enemy back to the location if it came from wandering if combat.get('from_wandering_enemy'): # Respawn enemy with current HP at the combat location import time despawn_time = time.time() + 300 # 5 minutes async with db.DatabaseSession() as session: from sqlalchemy import insert stmt = insert(db.wandering_enemies).values( npc_id=combat['npc_id'], location_id=combat['location_id'], spawn_timestamp=time.time(), despawn_timestamp=despawn_time ) await session.execute(stmt) await session.commit() await db.end_combat(player['id']) # Broadcast to location that player fled from combat await manager.send_to_location( location_id=combat['location_id'], message={ "type": "location_update", "data": { "message": get_game_message('player_fled_broadcast', locale, player_name=player['name']), "action": "combat_fled", "player_id": player['id'] }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] ) else: # Failed to flee, NPC attacks npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) new_player_hp = max(0, player['hp'] - npc_damage) messages.append(create_combat_message( "flee_fail", origin="enemy", npc_name=npc_def.name, damage=npc_damage, message=get_game_message('flee_fail_text', locale, name=player['name']) )) if new_player_hp <= 0: messages.append(create_combat_message( "player_defeated", origin="neutral", npc_name=npc_def.name )) combat_over = True await db.update_player(player['id'], hp=0, is_dead=True) await db.update_player_statistics(player['id'], deaths=1, failed_flees=1, damage_taken=npc_damage, increment=True) # Create corpse with player's inventory import json import time as time_module inventory = await db.get_inventory(player['id']) inventory_items = [] for inv_item in inventory: item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) inventory_items.append({ 'item_id': inv_item['item_id'], 'name': item_def.name if item_def else inv_item['item_id'], 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else 'šŸ“¦', 'quantity': inv_item['quantity'], 'durability': inv_item.get('durability'), 'max_durability': inv_item.get('max_durability'), 'tier': inv_item.get('tier') }) # Only create corpse if player has items corpse_data = None if inventory_items: logger.info(f"Creating player corpse (failed flee) for {player['name']} at {combat['location_id']} with {len(inventory_items)} items") corpse_id = await db.create_player_corpse( player_name=player['name'], location_id=combat['location_id'], items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) ) logger.info(f"Successfully created player corpse (failed flee): ID={corpse_id}, player={player['name']}, location={combat['location_id']}, items_count={len(inventory_items)}") # Clear player's inventory (items are now in corpse) await db.clear_inventory(player['id']) # Build corpse data for broadcast corpse_data = { "id": f"player_{corpse_id}", "type": "player", "name": f"{player['name']}'s Corpse", "emoji": "āš°ļø", "player_name": player['name'], "loot_count": len(inventory_items), "items": inventory_items, "timestamp": time_module.time() } else: logger.info(f"Player {player['name']} died (failed flee) with no items, skipping corpse creation") # Respawn enemy if from wandering if combat.get('from_wandering_enemy'): import time despawn_time = time.time() + 300 async with db.DatabaseSession() as session: from sqlalchemy import insert stmt = insert(db.wandering_enemies).values( npc_id=combat['npc_id'], location_id=combat['location_id'], spawn_timestamp=time.time(), despawn_timestamp=despawn_time ) await session.execute(stmt) await session.commit() await db.end_combat(player['id']) # Broadcast to location that player died (and corpse if created) logger.info(f"Broadcasting player_died (failed flee) to location {combat['location_id']} for player {player['name']}") broadcast_data = { "message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']), "action": "player_died", "player_id": player['id'] } if corpse_data: broadcast_data["corpse"] = corpse_data await manager.send_to_location( location_id=combat['location_id'], message={ "type": "location_update", "data": broadcast_data, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] ) else: # Player survived, update HP and turn back to player await db.update_player(player['id'], hp=new_player_hp) await db.update_player_statistics(player['id'], failed_flees=1, damage_taken=npc_damage, increment=True) await db.update_combat(player['id'], {'turn': 'player', 'turn_started_at': time.time()}) # Get updated combat state if not over updated_combat = None if not combat_over: raw_combat = await db.get_active_combat(current_user['id']) if raw_combat: # Calculate time remaining for turn import time turn_time_remaining = None if raw_combat['turn'] == 'player': turn_started_at = raw_combat.get('turn_started_at', 0) time_elapsed = time.time() - turn_started_at turn_time_remaining = max(0, 300 - time_elapsed) updated_combat = { "npc_id": raw_combat['npc_id'], "npc_name": npc_def.name, "npc_hp": raw_combat['npc_hp'], "npc_max_hp": raw_combat['npc_max_hp'], "npc_image": f"{npc_def.image_path}", "turn": raw_combat['turn'], "round": raw_combat.get('round', 1), "turn_time_remaining": turn_time_remaining } # Get fresh player data with updated HP after NPC attack updated_player = await db.get_player_by_id(current_user['id']) if not updated_player: updated_player = current_user # Fallback to current_user if something went wrong # Note: PvE combat_update WebSocket removed - frontend uses client-side timer # and polling for AFK timeout handling. WebSocket still used for PvP. return { "success": True, "messages": messages, "combat_over": combat_over, "player_won": player_won if combat_over else None, "combat": updated_combat if updated_combat else None, "player": { "hp": updated_player['hp'], "max_hp": updated_player.get('max_hp', updated_player.get('max_health')), "xp": updated_player['xp'], "level": updated_player['level'] } } @router.post("/api/game/pvp/initiate") async def initiate_pvp_combat( req: PvPCombatInitiateRequest, request: Request, current_user: dict = Depends(get_current_user) ): """Initiate PvP combat with another player""" # Get attacker (current user) attacker = await db.get_player_by_id(current_user['id']) if not attacker: raise HTTPException(status_code=404, detail="Player not found") # Extract locale locale = request.headers.get('Accept-Language', 'en') # Check if attacker is already in combat existing_combat = await db.get_active_combat(attacker['id']) if existing_combat: raise HTTPException(status_code=400, detail="You are already in PvE combat") existing_pvp = await db.get_pvp_combat_by_player(attacker['id']) if existing_pvp: raise HTTPException(status_code=400, detail="You are already in PvP combat") # Get defender (target player) defender = await db.get_player_by_id(req.target_player_id) if not defender: raise HTTPException(status_code=404, detail="Target player not found") # Check if defender is in combat defender_pve = await db.get_active_combat(defender['id']) if defender_pve: raise HTTPException(status_code=400, detail="Target player is in PvE combat") defender_pvp = await db.get_pvp_combat_by_player(defender['id']) if defender_pvp: raise HTTPException(status_code=400, detail="Target player is in PvP combat") # Check same location if attacker['location_id'] != defender['location_id']: raise HTTPException(status_code=400, detail="Target player is not in your location") # Check danger level (>= 3 required for PvP) location = LOCATIONS.get(attacker['location_id']) if not location or location.danger_level < 3: raise HTTPException(status_code=400, detail="PvP combat is only allowed in dangerous zones (danger level >= 3)") level_diff = abs(attacker['level'] - defender['level']) if level_diff > 3: raise HTTPException( status_code=400, detail=f"Level difference too large! You can only fight players within 3 levels (target is level {defender['level']})" ) # Create PvP combat pvp_combat = await db.create_pvp_combat( attacker_id=attacker['id'], defender_id=req.target_player_id, location_id=attacker['location_id'], turn_timeout=PVP_TURN_TIMEOUT ) # Track PvP combat initiation await db.update_player_statistics(attacker['id'], pvp_combats_initiated=1, increment=True) # Send WebSocket notifications to both players await manager.send_personal_message(attacker['id'], { "type": "combat_started", "data": { "message": get_game_message('pvp_initiated_attacker', locale, defender=defender['name']), "pvp_combat": pvp_combat }, "timestamp": datetime.utcnow().isoformat() }) await manager.send_personal_message(defender['id'], { "type": "combat_started", "data": { "message": get_game_message('pvp_challenged_defender', locale, attacker=attacker['name']), "pvp_combat": pvp_combat }, "timestamp": datetime.utcnow().isoformat() }) # Broadcast to location that PvP combat started - both players should be removed from view await manager.send_to_location( attacker['location_id'], { "type": "location_update", "data": { "message": get_game_message('pvp_combat_started_broadcast', locale, attacker=attacker['name'], defender=defender['name']), "action": "pvp_combat_started", "players_in_combat": [attacker['id'], defender['id']], "player_left_ids": [attacker['id'], defender['id']] # Remove both from location view }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=None # Send to everyone including combatants ) return { "success": True, "message": get_game_message('pvp_initiated_attacker', locale, defender=defender['name']), "pvp_combat": pvp_combat } @router.get("/api/game/pvp/status") async def get_pvp_combat_status(current_user: dict = Depends(get_current_user)): """Get current PvP combat status""" pvp_combat = await db.get_pvp_combat_by_player(current_user['id']) if not pvp_combat: return {"in_pvp_combat": False, "pvp_combat": None} # Check if current player has already acknowledged - if so, don't show combat anymore is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] if (is_attacker and pvp_combat.get('attacker_acknowledged', False)) or \ (not is_attacker and pvp_combat.get('defender_acknowledged', False)): return {"in_pvp_combat": False, "pvp_combat": None} # Get both players' data attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) defender = await db.get_player_by_id(pvp_combat['defender_character_id']) # Determine if current user is attacker or defender is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] your_turn = (is_attacker and pvp_combat['turn'] == 'attacker') or \ (not is_attacker and pvp_combat['turn'] == 'defender') # Calculate time remaining for turn import time time_elapsed = time.time() - pvp_combat['turn_started_at'] time_remaining = max(0, pvp_combat['turn_timeout_seconds'] - time_elapsed) # Auto-advance if time expired if time_remaining == 0 and your_turn: # Skip turn new_turn = 'defender' if is_attacker else 'attacker' await db.update_pvp_combat(pvp_combat['id'], { 'turn': new_turn, 'turn_started_at': time.time() }) pvp_combat = await db.get_pvp_combat_by_id(pvp_combat['id']) your_turn = False time_remaining = pvp_combat['turn_timeout_seconds'] return { "in_pvp_combat": True, "pvp_combat": { "id": pvp_combat['id'], "attacker": { "id": attacker['id'], "username": attacker['name'], "level": attacker['level'], "hp": attacker['hp'], # Use actual player HP "max_hp": attacker['max_hp'], "image": "/images/characters/default.webp" }, "defender": { "id": defender['id'], "username": defender['name'], "level": defender['level'], "hp": defender['hp'], # Use actual player HP "max_hp": defender['max_hp'], "image": "/images/characters/default.webp" }, "is_attacker": is_attacker, "your_turn": your_turn, "current_turn": pvp_combat['turn'], "time_remaining": int(time_remaining), "location_id": pvp_combat['location_id'], "last_action": pvp_combat.get('last_action'), "combat_over": pvp_combat.get('attacker_fled', False) or pvp_combat.get('defender_fled', False) or \ attacker['hp'] <= 0 or defender['hp'] <= 0, "attacker_fled": pvp_combat.get('attacker_fled', False), "defender_fled": pvp_combat.get('defender_fled', False) } } class PvPAcknowledgeRequest(BaseModel): combat_id: int @router.post("/api/game/pvp/acknowledge") async def acknowledge_pvp_combat( req: PvPAcknowledgeRequest, request: Request, current_user: dict = Depends(get_current_user) ): """Acknowledge PvP combat end""" await db.acknowledge_pvp_combat(req.combat_id, current_user['id']) # Extract locale from Accept-Language header locale = request.headers.get('Accept-Language', 'en') # Broadcast to location that player has returned player = current_user # current_user is already the character dict if player: await manager.send_to_location( location_id=player['location_id'], message={ "type": "player_arrived", "data": { "player_id": player['id'], "username": player['name'], "message": get_game_message('player_returned_pvp', locale, player_name=player['name']) }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] ) return {"success": True} class PvPCombatActionRequest(BaseModel): action: str # 'attack', 'flee', 'use_item' item_id: Optional[str] = None # For use_item action @router.post("/api/game/pvp/action") async def pvp_combat_action( req: PvPCombatActionRequest, request: Request, current_user: dict = Depends(get_current_user) ): """Perform a PvP combat action""" import random import time # Extract locale locale = request.headers.get('Accept-Language', 'en') # Get PvP combat pvp_combat = await db.get_pvp_combat_by_player(current_user['id']) if not pvp_combat: raise HTTPException(status_code=400, detail="Not in PvP combat") # Determine roles is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] your_turn = (is_attacker and pvp_combat['turn'] == 'attacker') or \ (not is_attacker and pvp_combat['turn'] == 'defender') if not your_turn: raise HTTPException(status_code=400, detail="It's not your turn") # Get both players attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) defender = await db.get_player_by_id(pvp_combat['defender_character_id']) current_player = attacker if is_attacker else defender opponent = defender if is_attacker else attacker messages = [] combat_over = False winner_id = None # Track the last action string for DB history last_action_text = "" if req.action == 'attack': # Calculate damage (similar to PvE) base_damage = 5 strength_bonus = current_player['strength'] * 2 level_bonus = current_player['level'] # Check for equipped weapon weapon_damage = 0 equipment = await db.get_all_equipment(current_player['id']) if equipment.get('weapon') and equipment['weapon']: weapon_slot = equipment['weapon'] inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id']) if inv_item: weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id']) if weapon_def and weapon_def.stats: weapon_damage = random.randint( weapon_def.stats.get('damage_min', 0), weapon_def.stats.get('damage_max', 0) ) # Decrease weapon durability if inv_item.get('unique_item_id'): new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1) if new_durability is None: messages.append(create_combat_message( "weapon_broke", origin="player", item_name=weapon_def.name )) await db.unequip_item(current_player['id'], 'weapon') variance = random.randint(-2, 2) damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) # Apply armor reduction and durability loss to opponent armor_absorbed, broken_armor = await reduce_armor_durability(opponent['id'], damage) actual_damage = max(1, damage - armor_absorbed) # Structure the attack message messages.append(create_combat_message( "player_attack", origin="player", damage=damage, armor_absorbed=armor_absorbed )) # Update opponent HP (use actual player HP, not pvp_combat fields) new_opponent_hp = max(0, opponent['hp'] - actual_damage) # Update opponent's HP in database await db.update_player(opponent['id'], hp=new_opponent_hp) # Construct summary string for DB history/passive player last_action_text = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!" if armor_absorbed > 0: last_action_text += f" (Armor absorbed {armor_absorbed})" for broken in broken_armor: messages.append(create_combat_message( "item_broken", origin="enemy", # Belongs to opponent item_name=broken['name'], emoji=broken['emoji'] )) last_action_text += f"\nšŸ’” {opponent['name']}'s {broken['emoji']} {broken['name']} broke!" # Check if opponent defeated if new_opponent_hp <= 0: last_action_text += f"\nšŸ† {current_player['name']} has defeated {opponent['name']}!" messages.append(create_combat_message( "victory", origin="neutral", npc_name=opponent['name'] )) combat_over = True winner_id = current_player['id'] # Update opponent to dead state await db.update_player(opponent['id'], hp=0, is_dead=True) # Create corpse with opponent's inventory import json import time as time_module inventory = await db.get_inventory(opponent['id']) inventory_items = [] for inv_item in inventory: item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) inventory_items.append({ 'item_id': inv_item['item_id'], 'name': item_def.name if item_def else inv_item['item_id'], 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else 'šŸ“¦', 'quantity': inv_item['quantity'], 'durability': inv_item.get('durability'), 'max_durability': inv_item.get('max_durability'), 'tier': inv_item.get('tier') }) # Only create corpse if opponent has items corpse_data = None if inventory_items: logger.info(f"Creating player corpse (PvP death) for {opponent['name']} at {opponent['location_id']} with {len(inventory_items)} items") corpse_id = await db.create_player_corpse( player_name=opponent['name'], location_id=opponent['location_id'], items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) ) logger.info(f"Successfully created player corpse (PvP death): ID={corpse_id}, player={opponent['name']}, location={opponent['location_id']}, items_count={len(inventory_items)}") # Clear opponent's inventory (items are now in corpse) await db.clear_inventory(opponent['id']) # Build corpse data for broadcast corpse_data = { "id": f"player_{corpse_id}", "type": "player", "name": f"{opponent['name']}'s Corpse", "emoji": "āš°ļø", "player_name": opponent['name'], "loot_count": len(inventory_items), "items": inventory_items, "timestamp": time_module.time() } else: logger.info(f"Player {opponent['name']} died (PvP death) with no items, skipping corpse creation") # Update PvP statistics for both players await db.update_player_statistics(opponent['id'], pvp_deaths=1, pvp_combats_lost=1, pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True ) await db.update_player_statistics(current_player['id'], players_killed=1, pvp_combats_won=1, pvp_damage_dealt=damage, pvp_attacks_landed=1, increment=True ) # Broadcast to location that player died (and corpse if created) logger.info(f"Broadcasting player_died (PvP death) to location {opponent['location_id']} for player {opponent['name']}") broadcast_data = { "message": get_game_message('pvp_defeat_broadcast', locale, opponent=opponent['name'], winner=current_player['name']), "action": "player_died", "player_id": opponent['id'] } if corpse_data: broadcast_data["corpse"] = corpse_data await manager.send_to_location( location_id=opponent['location_id'], message={ "type": "location_update", "data": broadcast_data, "timestamp": datetime.utcnow().isoformat() } ) # End PvP combat await db.end_pvp_combat(pvp_combat['id']) else: # Combat continues # Update PvP statistics for attack await db.update_player_statistics(current_player['id'], pvp_damage_dealt=damage, pvp_attacks_landed=1, increment=True ) await db.update_player_statistics(opponent['id'], pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True ) # Update combat state and switch turns # Add timestamp to make each action unique for duplicate detection updates = { 'turn': 'defender' if is_attacker else 'attacker', 'turn_started_at': time.time(), 'last_action': f"{last_action_text}|{time.time()}" # Add timestamp for uniqueness } # No need to update HP in pvp_combat - we use player HP directly await db.update_pvp_combat(pvp_combat['id'], updates) await db.update_player_statistics(current_player['id'], damage_dealt=damage, increment=True) elif req.action == 'flee': # 50% chance to flee from PvP if random.random() < 0.5: last_action_text = get_game_message('flee_success_text', locale, name=current_player['name']) messages.append(create_combat_message( "flee_success", origin="player", message=last_action_text )) combat_over = True # Mark as fled, store last action with timestamp, and end combat flee_field = 'attacker_fled' if is_attacker else 'defender_fled' await db.update_pvp_combat(pvp_combat['id'], { flee_field: True, 'last_action': f"{last_action_text}|{time.time()}" }) await db.end_pvp_combat(pvp_combat['id']) await db.update_player_statistics(current_player['id'], pvp_successful_flees=1, increment=True ) else: # Failed to flee, skip turn last_action_text = get_game_message('flee_fail_text', locale, name=current_player['name']) messages.append(create_combat_message( "flee_fail", origin="player", reason="chance", message=last_action_text )) await db.update_pvp_combat(pvp_combat['id'], { 'turn': 'defender' if is_attacker else 'attacker', 'turn_started_at': time.time(), 'last_action': f"{last_action_text}|{time.time()}" }) await db.update_player_statistics(current_player['id'], pvp_failed_flees=1, increment=True ) # Send WebSocket combat updates to both players # Get fresh PvP combat data updated_pvp = await db.get_pvp_combat_by_id(pvp_combat['id']) # Get fresh player data for HP updates fresh_attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) fresh_defender = await db.get_player_by_id(pvp_combat['defender_character_id']) # Send to both players with enriched data (like the API endpoint does) for player_id in [pvp_combat['attacker_character_id'], pvp_combat['defender_character_id']]: is_attacker = player_id == pvp_combat['attacker_character_id'] your_turn = (is_attacker and updated_pvp['turn'] == 'attacker') or \ (not is_attacker and updated_pvp['turn'] == 'defender') # Calculate time remaining import time time_elapsed = time.time() - updated_pvp['turn_started_at'] time_remaining = max(0, updated_pvp['turn_timeout_seconds'] - time_elapsed) # Build enriched pvp_combat object like the API does enriched_pvp = { "id": updated_pvp['id'], "attacker": { "id": fresh_attacker['id'], "username": fresh_attacker['name'], "level": fresh_attacker['level'], "hp": fresh_attacker['hp'], "max_hp": fresh_attacker['max_hp'], "image": "/images/characters/default.webp" }, "defender": { "id": fresh_defender['id'], "username": fresh_defender['name'], "level": fresh_defender['level'], "hp": fresh_defender['hp'], "max_hp": fresh_defender['max_hp'], "image": "/images/characters/default.webp" }, "is_attacker": is_attacker, "your_turn": your_turn, "current_turn": updated_pvp['turn'], "time_remaining": int(time_remaining), "location_id": updated_pvp['location_id'], "last_action": updated_pvp.get('last_action'), "combat_over": updated_pvp.get('attacker_fled', False) or updated_pvp.get('defender_fled', False) or \ fresh_attacker['hp'] <= 0 or fresh_defender['hp'] <= 0, "attacker_fled": updated_pvp.get('attacker_fled', False), "defender_fled": updated_pvp.get('defender_fled', False) } # Determine which player object to send as "player" data for global state updates player_data = { "id": fresh_attacker['id'], "username": fresh_attacker['name'], "level": fresh_attacker['level'], "hp": fresh_attacker['hp'], "max_hp": fresh_attacker['max_hp'], "xp": fresh_attacker['xp'], "max_xp": fresh_attacker['level'] * 1000 } if is_attacker else { "id": fresh_defender['id'], "username": fresh_defender['name'], "level": fresh_defender['level'], "hp": fresh_defender['hp'], "max_hp": fresh_defender['max_hp'], "xp": fresh_defender['xp'], "max_xp": fresh_defender['level'] * 1000 } # Process messages for this player # Use actor_id (current_player['id']) to identify who performed the action # If I am NOT the actor, then the action was done BY an enemy against me. # So I swap 'player' origin (Actor) to 'enemy' origin (Attacker from my perspective). actor_id = current_player['id'] import copy player_messages = [] is_actor = (player_id == actor_id) # For the victim (non-actor), we strip the pre-generated text messages so frontend can generate # "Enemy hit you" instead of "Alice hit Bob" if not is_actor: msgs_copy = copy.deepcopy(messages) for m in msgs_copy: if m.get('origin') == 'player': m['origin'] = 'enemy' elif m.get('origin') == 'enemy': m['origin'] = 'player' player_messages.append(m) else: player_messages = messages # Send separate payloads # For actor: keep full text # For victim: strip main message text so frontend uses data to render "Enemy hit you" payload_data = { "message": last_action_text if is_actor else None, # key refactor: hide text for victim "log_entry": last_action_text if is_actor else None, "pvp_combat": enriched_pvp, "combat_over": combat_over, "winner_id": winner_id, "player": player_data, "attacker_hp": fresh_attacker['hp'], "defender_hp": fresh_defender['hp'], "messages": player_messages } await manager.send_personal_message(player_id, { "type": "combat_update", "data": payload_data, "timestamp": datetime.utcnow().isoformat() }) return { "success": True, "messages": messages, "combat_over": combat_over, "winner_id": winner_id, "pvp_combat": updated_pvp }