1280 lines
51 KiB
Python
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
|
|
} |