""" 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, get_resolved_player_effects 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 from ..services.status_effects import status_effects_manager 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) # Parse NPC status effects npc_effects_list = [] npc_status_str = combat.get('npc_status_effects', '') or '' if npc_status_str: for part in npc_status_str.split('|'): tokens = part.split(':') effect_name = tokens[0] if len(tokens) > 0 else '' if not effect_name: continue ticks = int(tokens[2]) if len(tokens) > 2 else (int(tokens[1]) if len(tokens) > 1 else 0) info = status_effects_manager.get_effect_info(effect_name) npc_effects_list.append({ 'name': info['name'], 'icon': info['icon'], 'ticks_remaining': ticks, 'description': info['description'], }) # Get player active buffs/debuffs (exclude cooldowns) player_effects = await get_resolved_player_effects(current_user['id'], in_combat=True) 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, "npc_effects": npc_effects_list, "npc_intent": combat.get('npc_intent', 'attack') }, "player_effects": player_effects } @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, "npc_intent": "attack" }, "player_effects": await get_resolved_player_effects(current_user['id'], in_combat=True) }, "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, "npc_intent": "attack" }, "player_effects": await get_resolved_player_effects(current_user['id'], in_combat=True) } @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: # Fetch fresh combat state to capture any player buffs applied fresh_combat = await db.get_active_combat(player['id']) st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '') # 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': st_effects}, npc_def, reduce_armor_durability, redis_manager, locale=locale ) 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, locale=locale ) 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: # Fetch fresh combat state to capture effects applied by the skill fresh_combat = await db.get_active_combat(player['id']) st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '') # 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': st_effects}, npc_def, reduce_armor_durability, redis_manager, locale=locale ) messages.extend(npc_msgs) if player_defeated: await db.remove_non_persistent_effects(player['id']) combat_over = True else: await db.update_combat(player['id'], {'npc_hp': new_npc_hp}) # ── 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: # Fetch fresh combat state to capture effects applied by the item fresh_combat = await db.get_active_combat(player['id']) st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '') # 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': st_effects}, npc_def, reduce_armor_durability, redis_manager, locale=locale ) 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']}) # ── DEFEND ── elif req.action == 'defend': result = await combat_engine.execute_defend( player_id=player['id'], player=player, player_stats=stats, is_pvp=False, locale=locale, ) messages.extend(result['messages']) # Fetch fresh combat state since defend could've updated stats (stamina) fresh_combat = await db.get_active_combat(player['id']) st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '') # NPC turn after defend npc_msgs, player_defeated = await combat_engine.execute_npc_turn( player['id'], {'npc_hp': combat['npc_hp'], 'npc_max_hp': combat['npc_max_hp'], 'npc_intent': combat.get('npc_intent', 'attack'), 'npc_status_effects': st_effects}, npc_def, reduce_armor_durability, redis_manager, locale=locale ) messages.extend(npc_msgs) if player_defeated: await db.remove_non_persistent_effects(player['id']) combat_over = True # ── 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 npc_effects_list = [] 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)) # Parse NPC status effects string into a list npc_status_str = raw_combat.get('npc_status_effects', '') or '' if npc_status_str: for part in npc_status_str.split('|'): tokens = part.split(':') effect_name = tokens[0] if len(tokens) > 0 else '' if not effect_name: continue ticks = int(tokens[2]) if len(tokens) > 2 else (int(tokens[1]) if len(tokens) > 1 else 0) info = status_effects_manager.get_effect_info(effect_name) npc_effects_list.append({ 'name': info['name'], 'icon': info['icon'], 'ticks_remaining': ticks, 'description': info['description'], }) 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, "npc_effects": npc_effects_list, "npc_intent": raw_combat.get('npc_intent', 'attack') } # Get player active buffs/debuffs (exclude cooldowns) player_effects = [] if not combat_over: from ..services.skills import skills_manager all_effects = await db.get_player_effects(current_user['id']) for eff in all_effects: if eff.get('effect_type') == 'cooldown': continue resolved = status_effects_manager.resolve_player_effect( eff.get('effect_name', ''), eff.get('effect_icon', '⚡'), eff.get('source', ''), skills_manager ) player_effects.append({ 'name': resolved['name'], 'icon': resolved['icon'], 'ticks_remaining': eff.get('ticks_remaining', 0), 'type': eff.get('effect_type', 'buff'), 'description': resolved['description'], }) updated_player = await db.get_player_by_id(current_user['id']) if not updated_player: updated_player = current_user equipment_slots = await db.get_all_equipment(current_user['id']) equipment = {} for slot, item_data in equipment_slots.items(): if item_data and item_data['item_id']: inv_item = await db.get_inventory_item_by_id(item_data['item_id']) if inv_item: item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) if item_def: # Get unique item data if this is a unique item durability = None max_durability = None tier = None unique_stats = None if inv_item.get('unique_item_id'): unique_item = await db.get_unique_item(inv_item['unique_item_id']) if unique_item: durability = unique_item.get('durability') max_durability = unique_item.get('max_durability') tier = unique_item.get('tier') unique_stats = unique_item.get('unique_stats') equipment[slot] = { "inventory_id": item_data['item_id'], "item_id": item_def.id, "name": item_def.name, "description": item_def.description, "emoji": item_def.emoji, "image_path": item_def.image_path, "durability": durability if durability is not None else None, "max_durability": max_durability if max_durability is not None else None, "tier": tier if tier is not None else None, "unique_stats": unique_stats, "stats": item_def.stats, "encumbrance": item_def.encumbrance, "weapon_effects": item_def.weapon_effects if hasattr(item_def, 'weapon_effects') else {} } if slot not in equipment: equipment[slot] = None 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'] }, "player_effects": player_effects, "equipment": equipment, "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, locale=locale ) 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()}" }) # ── DEFEND ── elif req.action == 'defend': result = await combat_engine.execute_defend( player_id=current_player['id'], player=current_player, player_stats=current_player_stats, is_pvp=True, locale=locale, ) messages.extend(result['messages']) last_action_text = f"{current_player['name']} took a defensive stance!" # 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 }