Files
echoes-of-the-ash/api/routers/combat.py

1280 lines
51 KiB
Python

"""
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
}