""" Combat router. Thin orchestration layer — all combat logic lives in services/combat_engine.py. PvE uses REST responses + location WebSocket broadcasts. PvP uses REST responses + personal WebSocket messages to both players. """ 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 copy import time 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 from ..services import combat_engine logger = logging.getLogger(__name__) # These will be injected by main.py LOCATIONS = None ITEMS_MANAGER = None WORLD = None redis_manager = None QUESTS_DATA = {} def init_router_dependencies(locations, items_manager, world, redis_mgr=None, quests_data=None): """Initialize router with game data dependencies""" global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA LOCATIONS = locations ITEMS_MANAGER = items_manager WORLD = world redis_manager = redis_mgr if quests_data: QUESTS_DATA = quests_data router = APIRouter(tags=["combat"]) # ============================================================================ # PvE 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} import sys sys.path.insert(0, '/app') from data.npcs import NPCS npc_def = NPCS.get(combat['npc_id']) # Calculate time remaining for turn 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) 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 sys sys.path.insert(0, '/app') from data.npcs import NPCS 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") 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 wandering enemy 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 stats await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True) player = current_user # WebSocket to 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 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 PvE combat action — delegates to combat_engine""" import sys sys.path.insert(0, '/app') from data.npcs import NPCS 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") # Anti rapid-fire check current_round = combat.get('round', 1) if current_round > 1: turn_started_at = combat.get('turn_started_at', 0) if time.time() - turn_started_at < 2.0: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Please wait for the enemy's turn to complete") player = current_user npc_def = NPCS.get(combat['npc_id']) # Get derived stats from ..services.stats import calculate_derived_stats stats = await calculate_derived_stats(player['id'], redis_manager) messages = [] combat_over = False player_won = False quest_updates = [] # ── Process status effects before action ── effect_msgs, player_hp, player_died = await combat_engine.process_status_effects( player['id'], player, stats['max_hp'] ) messages.extend(effect_msgs) player['hp'] = player_hp if player_died: await db.remove_non_persistent_effects(player['id']) await db.end_combat(player['id']) return { "player": player, "combat": None, "messages": messages + [create_combat_message("died", origin="player", message="You died from status effects!")], "active_effects": [], "round": combat['round'] } # Build target dict for the engine npc_target = { 'id': combat['npc_id'], 'hp': combat['npc_hp'], 'max_hp': combat['npc_max_hp'], 'defense': getattr(npc_def, 'defense', 0), 'name': npc_def.name, 'type': 'npc', } # ── ATTACK ── if req.action == 'attack': result = await combat_engine.execute_attack( attacker_id=player['id'], attacker=player, attacker_stats=stats, target=npc_target, is_pvp=False, items_manager=ITEMS_MANAGER, reduce_armor_func=reduce_armor_durability, ) messages.extend(result['messages']) new_npc_hp = result['target_hp'] if result['target_defeated']: # Victory messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name)) combat_over = True player_won = True victory = await combat_engine.handle_victory_pve( player, combat, npc_def, ITEMS_MANAGER, QUESTS_DATA, redis_manager, locale ) messages.extend(victory['messages']) quest_updates = victory.get('quest_updates', []) # Track damage stat await db.update_player_statistics(player['id'], damage_dealt=result['damage_dealt'], increment=True) # Broadcast 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 turn npc_msgs, player_defeated = await combat_engine.execute_npc_turn( player['id'], {'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp'], 'npc_intent': combat.get('npc_intent', 'attack'), 'npc_status_effects': combat.get('npc_status_effects', '')}, npc_def, reduce_armor_durability, redis_manager ) messages.extend(npc_msgs) if player_defeated: combat_over = True else: await db.update_combat(player['id'], {'npc_hp': new_npc_hp}) # ── SKILL ── elif req.action == 'skill': if not req.skill_id: raise HTTPException(status_code=400, detail="skill_id required for skill action") result = await combat_engine.execute_skill( player_id=player['id'], player=player, player_stats=stats, target=npc_target, skill_id=req.skill_id, combat_state=combat, is_pvp=False, items_manager=ITEMS_MANAGER, reduce_armor_func=reduce_armor_durability, redis_manager=redis_manager, ) if result.get('error'): raise HTTPException(status_code=result.get('status_code', 400), detail=result['error']) messages.extend(result['messages']) new_npc_hp = result['target_hp'] if result['target_defeated']: # Victory via skill messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name)) combat_over = True player_won = True victory = await combat_engine.handle_victory_pve( player, combat, npc_def, ITEMS_MANAGER, QUESTS_DATA, redis_manager, locale ) messages.extend(victory['messages']) quest_updates = victory.get('quest_updates', []) # Broadcast 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 turn after skill npc_msgs, player_defeated = await combat_engine.execute_npc_turn( player['id'], {'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp'], 'npc_intent': combat.get('npc_intent', 'attack'), 'npc_status_effects': combat.get('npc_status_effects', '')}, npc_def, reduce_armor_durability, redis_manager ) messages.extend(npc_msgs) if player_defeated: await db.remove_non_persistent_effects(player['id']) combat_over = True # ── USE ITEM ── elif req.action == 'use_item': if not req.item_id: raise HTTPException(status_code=400, detail="item_id required for use_item action") result = await combat_engine.execute_use_item( player_id=player['id'], player=player, player_stats=stats, item_id=req.item_id, combat_state=combat, target=npc_target, is_pvp=False, items_manager=ITEMS_MANAGER, locale=locale, ) if result.get('error'): raise HTTPException(status_code=result.get('status_code', 400), detail=result['error']) messages.extend(result['messages']) if result['target_defeated']: # Victory via item (throwable) combat_over = True player_won = True victory = await combat_engine.handle_victory_pve( player, combat, npc_def, ITEMS_MANAGER, QUESTS_DATA, redis_manager, locale ) messages.extend(victory['messages']) quest_updates = victory.get('quest_updates', []) elif not combat_over: # NPC turn after item use npc_msgs, player_defeated = await combat_engine.execute_npc_turn( player['id'], {'npc_hp': result.get('target_hp', combat['npc_hp']), 'npc_max_hp': combat['npc_max_hp'], 'npc_intent': combat.get('npc_intent', 'attack'), 'npc_status_effects': combat.get('npc_status_effects', '')}, npc_def, reduce_armor_durability, redis_manager ) messages.extend(npc_msgs) if player_defeated: await db.remove_non_persistent_effects(player['id']) combat_over = True else: # Update NPC HP from throwable damage if result.get('target_hp') is not None and result['target_hp'] != combat['npc_hp']: await db.update_combat(player['id'], {'npc_hp': result['target_hp']}) # ── FLEE ── elif req.action == 'flee': result = await combat_engine.execute_flee_pve( player_id=player['id'], player=player, player_stats=stats, combat=combat, npc_def=npc_def, reduce_armor_func=reduce_armor_durability, locale=locale, ) messages.extend(result['messages']) combat_over = result['combat_over'] player_won = result.get('success', False) if result.get('corpse_data'): # Broadcast death broadcast_data = { "message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']), "action": "player_died", "player_id": player['id'] } broadcast_data["corpse"] = result['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'] ) elif result.get('success'): # Broadcast flee 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'] ) # ── Build response ── updated_combat = None if not combat_over: raw_combat = await db.get_active_combat(current_user['id']) if raw_combat: turn_time_remaining = None if raw_combat['turn'] == 'player': turn_started_at = raw_combat.get('turn_started_at', 0) turn_time_remaining = max(0, 300 - (time.time() - turn_started_at)) 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 } updated_player = await db.get_player_by_id(current_user['id']) if not updated_player: updated_player = current_user 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'] }, "quest_updates": quest_updates } # ============================================================================ # PvP ENDPOINTS # ============================================================================ @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""" attacker = await db.get_player_by_id(current_user['id']) if not attacker: raise HTTPException(status_code=404, detail="Player not found") locale = request.headers.get('Accept-Language', 'en') # Validation checks 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") defender = await db.get_player_by_id(req.target_player_id) if not defender: raise HTTPException(status_code=404, detail="Target player not found") 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") if attacker['location_id'] != defender['location_id']: raise HTTPException(status_code=400, detail="Target player is not in your location") 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 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 ) await db.update_player_statistics(attacker['id'], pvp_combats_initiated=1, increment=True) # WebSocket 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 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']] }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=None ) 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} 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} attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) defender = await db.get_player_by_id(pvp_combat['defender_character_id']) your_turn = (is_attacker and pvp_combat['turn'] == 'attacker') or \ (not is_attacker and pvp_combat['turn'] == 'defender') 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: 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'], "max_hp": attacker['max_hp'], "image": "/images/characters/default.webp" }, "defender": { "id": defender['id'], "username": defender['name'], "level": defender['level'], "hp": defender['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) } } @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']) locale = request.headers.get('Accept-Language', 'en') player = current_user 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} @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 — delegates to combat_engine""" 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 # Get derived stats from ..services.stats import calculate_derived_stats current_player_stats = await calculate_derived_stats(current_player['id'], redis_manager) opponent_stats = await calculate_derived_stats(opponent['id'], redis_manager) messages = [] combat_over = False winner_id = None last_action_text = "" # ── Process status effects ── effect_msgs, player_hp, player_died = await combat_engine.process_status_effects( current_player['id'], current_player, current_player_stats.get('max_hp', current_player['max_hp']) ) messages.extend(effect_msgs) current_player['hp'] = player_hp if player_died: messages.append(create_combat_message("died", origin="player", message="You died from status effects!")) combat_over = True winner_id = opponent['id'] await db.update_player(current_player['id'], hp=0, is_dead=True) # Create corpse corpse_data = await combat_engine._create_player_corpse(current_player['id'], current_player, current_player['location_id'], ITEMS_MANAGER) await db.update_player_statistics(current_player['id'], pvp_deaths=1, pvp_combats_lost=1, increment=True) await db.update_player_statistics(opponent['id'], players_killed=1, pvp_combats_won=1, increment=True) # Broadcast broadcast_data = { "message": get_game_message('pvp_defeat_broadcast', locale, opponent=current_player['name'], winner=opponent['name']), "action": "player_died", "player_id": current_player['id'] } if corpse_data: broadcast_data["corpse"] = corpse_data await manager.send_to_location( location_id=current_player['location_id'], message={"type": "location_update", "data": broadcast_data, "timestamp": datetime.utcnow().isoformat()} ) await db.end_pvp_combat(pvp_combat['id']) # ── ATTACK ── elif req.action == 'attack': # Build target with opponent's stats for dodge/block pvp_target = { 'id': opponent['id'], 'hp': opponent['hp'], 'max_hp': opponent['max_hp'], 'defense': 0, 'name': opponent['name'], 'type': 'player', } result = await combat_engine.execute_attack( attacker_id=current_player['id'], attacker=current_player, attacker_stats=current_player_stats, target=pvp_target, is_pvp=True, items_manager=ITEMS_MANAGER, reduce_armor_func=reduce_armor_durability, ) messages.extend(result['messages']) new_opponent_hp = result['target_hp'] await db.update_player(opponent['id'], hp=new_opponent_hp) last_action_text = f"{current_player['name']} attacks {opponent['name']} for {result['damage_dealt']} damage!" if result['target_defeated']: last_action_text += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!" combat_over = True winner_id = current_player['id'] pvp_victory = await combat_engine.handle_victory_pvp( current_player, opponent, result['damage_dealt'], ITEMS_MANAGER, locale ) messages.extend(pvp_victory['messages']) # Broadcast 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 pvp_victory.get('corpse_data'): broadcast_data["corpse"] = pvp_victory['corpse_data'] await manager.send_to_location( location_id=opponent['location_id'], message={"type": "location_update", "data": broadcast_data, "timestamp": datetime.utcnow().isoformat()} ) await db.end_pvp_combat(pvp_combat['id']) else: # Update stats and switch turns await db.update_player_statistics(current_player['id'], pvp_damage_dealt=result['damage_dealt'], pvp_attacks_landed=1, increment=True) await db.update_player_statistics(opponent['id'], pvp_damage_taken=result['damage_dealt'], pvp_attacks_received=1, increment=True) 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()}" }) # ── SKILL ── elif req.action == 'skill': skill_id = req.skill_id or req.item_id # Support legacy item_id field if not skill_id: raise HTTPException(status_code=400, detail="skill_id required") pvp_target = { 'id': opponent['id'], 'hp': opponent['hp'], 'max_hp': opponent['max_hp'], 'defense': 0, 'name': opponent['name'], 'type': 'player', } result = await combat_engine.execute_skill( player_id=current_player['id'], player=current_player, player_stats=current_player_stats, target=pvp_target, skill_id=skill_id, combat_state={}, # No PvE combat state for PvP is_pvp=True, items_manager=ITEMS_MANAGER, reduce_armor_func=reduce_armor_durability, redis_manager=redis_manager, ) if result.get('error'): raise HTTPException(status_code=result.get('status_code', 400), detail=result['error']) messages.extend(result['messages']) damage_done = result['damage_dealt'] last_action_text = f"{current_player['name']} used a skill!" if damage_done > 0: await db.update_player(opponent['id'], hp=result['target_hp']) last_action_text = f"{current_player['name']} dealt {damage_done} damage with a skill!" if result['target_defeated']: last_action_text += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!" combat_over = True winner_id = current_player['id'] pvp_victory = await combat_engine.handle_victory_pvp( current_player, opponent, damage_done, ITEMS_MANAGER, locale ) messages.extend(pvp_victory['messages']) 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 pvp_victory.get('corpse_data'): broadcast_data["corpse"] = pvp_victory['corpse_data'] await manager.send_to_location( location_id=opponent['location_id'], message={"type": "location_update", "data": broadcast_data, "timestamp": datetime.utcnow().isoformat()} ) await db.end_pvp_combat(pvp_combat['id']) else: await db.update_player_statistics(current_player['id'], pvp_damage_dealt=damage_done, pvp_attacks_landed=1, increment=True) await db.update_player_statistics(opponent['id'], pvp_damage_taken=damage_done, pvp_attacks_received=1, increment=True) 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()}" }) else: # Non-damage skill (heal, buff) — switch turns 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()}" }) # ── USE ITEM ── elif req.action == 'use_item': if not req.item_id: raise HTTPException(status_code=400, detail="item_id required for use_item action") pvp_target = { 'id': opponent['id'], 'hp': opponent['hp'], 'max_hp': opponent['max_hp'], 'defense': 0, 'name': opponent['name'], 'type': 'player', } result = await combat_engine.execute_use_item( player_id=current_player['id'], player=current_player, player_stats=current_player_stats, item_id=req.item_id, combat_state={}, target=pvp_target, is_pvp=True, items_manager=ITEMS_MANAGER, locale=locale, ) if result.get('error'): raise HTTPException(status_code=result.get('status_code', 400), detail=result['error']) messages.extend(result['messages']) last_action_text = f"{current_player['name']} used an item!" # Switch turns 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()}" }) # ── FLEE ── elif req.action == 'flee': result = await combat_engine.execute_flee_pvp( player_id=current_player['id'], player=current_player, player_stats=current_player_stats, locale=locale, ) messages.extend(result['messages']) last_action_text = result.get('last_action', '') if result['success']: combat_over = True 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: 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 updates to both players ── updated_pvp = await db.get_pvp_combat_by_id(pvp_combat['id']) 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']) for player_id in [pvp_combat['attacker_character_id'], pvp_combat['defender_character_id']]: is_att = player_id == pvp_combat['attacker_character_id'] your_t = (is_att and updated_pvp['turn'] == 'attacker') or \ (not is_att and updated_pvp['turn'] == 'defender') time_elapsed = time.time() - updated_pvp['turn_started_at'] time_rem = max(0, updated_pvp['turn_timeout_seconds'] - time_elapsed) 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_att, "your_turn": your_t, "current_turn": updated_pvp['turn'], "time_remaining": int(time_rem), "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) } player_data = { "id": fresh_attacker['id'] if is_att else fresh_defender['id'], "username": fresh_attacker['name'] if is_att else fresh_defender['name'], "level": fresh_attacker['level'] if is_att else fresh_defender['level'], "hp": fresh_attacker['hp'] if is_att else fresh_defender['hp'], "max_hp": fresh_attacker['max_hp'] if is_att else fresh_defender['max_hp'], "xp": fresh_attacker['xp'] if is_att else fresh_defender['xp'], "max_xp": (fresh_attacker['level'] if is_att else fresh_defender['level']) * 1000 } actor_id = current_player['id'] is_actor = (player_id == actor_id) if not is_actor: msgs_copy = copy.deepcopy(messages) player_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 await manager.send_personal_message(player_id, { "type": "combat_update", "data": { "message": last_action_text if is_actor else None, "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 }, "timestamp": datetime.utcnow().isoformat() }) return { "success": True, "messages": messages, "combat_over": combat_over, "winner_id": winner_id, "pvp_combat": updated_pvp }