1060 lines
43 KiB
Python
1060 lines
43 KiB
Python
"""
|
|
Combat router.
|
|
Auto-generated from main.py migration.
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Depends, status
|
|
from fastapi.security import HTTPAuthorizationCredentials
|
|
from typing import Optional, Dict, Any
|
|
from datetime import datetime
|
|
import random
|
|
import json
|
|
import logging
|
|
|
|
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
|
|
from .. import database as db
|
|
from ..items import ItemsManager
|
|
from .. import game_logic
|
|
from ..core.websockets import manager
|
|
from .equipment import reduce_armor_durability
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# These will be injected by main.py
|
|
LOCATIONS = None
|
|
ITEMS_MANAGER = None
|
|
WORLD = None
|
|
redis_manager = None
|
|
|
|
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
|
"""Initialize router with game data dependencies"""
|
|
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
|
LOCATIONS = locations
|
|
ITEMS_MANAGER = items_manager
|
|
WORLD = world
|
|
redis_manager = redis_mgr
|
|
|
|
router = APIRouter(tags=["combat"])
|
|
|
|
|
|
|
|
# Endpoints
|
|
|
|
@router.get("/api/game/combat")
|
|
async def get_combat_status(current_user: dict = Depends(get_current_user)):
|
|
"""Get current combat status"""
|
|
combat = await db.get_active_combat(current_user['id'])
|
|
if not combat:
|
|
return {"in_combat": False}
|
|
|
|
# Load NPC data from npcs.json
|
|
import sys
|
|
sys.path.insert(0, '/app')
|
|
from data.npcs import NPCS
|
|
npc_def = NPCS.get(combat['npc_id'])
|
|
|
|
# Calculate time remaining for turn (server-side to avoid clock offset)
|
|
import time
|
|
turn_time_remaining = None
|
|
if combat['turn'] == 'player':
|
|
turn_started_at = combat.get('turn_started_at', 0)
|
|
time_elapsed = time.time() - turn_started_at
|
|
turn_time_remaining = max(0, 300 - time_elapsed) # 5 minutes = 300 seconds
|
|
|
|
return {
|
|
"in_combat": True,
|
|
"combat": {
|
|
"npc_id": combat['npc_id'],
|
|
"npc_name": npc_def.name if npc_def else combat['npc_id'].replace('_', ' ').title(),
|
|
"npc_hp": combat['npc_hp'],
|
|
"npc_max_hp": combat['npc_max_hp'],
|
|
"npc_image": f"{npc_def.image_path}" if npc_def else None,
|
|
"turn": combat['turn'],
|
|
"round": combat.get('round', 1),
|
|
"turn_time_remaining": turn_time_remaining
|
|
}
|
|
}
|
|
|
|
|
|
@router.post("/api/game/combat/initiate")
|
|
async def initiate_combat(
|
|
req: InitiateCombatRequest,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Start combat with a wandering enemy"""
|
|
import random
|
|
import sys
|
|
sys.path.insert(0, '/app')
|
|
from data.npcs import NPCS
|
|
|
|
# Check if already in combat
|
|
existing_combat = await db.get_active_combat(current_user['id'])
|
|
if existing_combat:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Already in combat"
|
|
)
|
|
|
|
# Get enemy from wandering_enemies table
|
|
async with db.DatabaseSession() as session:
|
|
from sqlalchemy import select
|
|
stmt = select(db.wandering_enemies).where(db.wandering_enemies.c.id == req.enemy_id)
|
|
result = await session.execute(stmt)
|
|
enemy = result.fetchone()
|
|
|
|
if not enemy:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Enemy not found"
|
|
)
|
|
|
|
# Get NPC definition
|
|
npc_def = NPCS.get(enemy.npc_id)
|
|
if not npc_def:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="NPC definition not found"
|
|
)
|
|
|
|
# Randomize HP
|
|
npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max)
|
|
|
|
# Create combat
|
|
combat = await db.create_combat(
|
|
player_id=current_user['id'],
|
|
npc_id=enemy.npc_id,
|
|
npc_hp=npc_hp,
|
|
npc_max_hp=npc_hp,
|
|
location_id=current_user['location_id'],
|
|
from_wandering=True
|
|
)
|
|
|
|
# Remove the wandering enemy from the location
|
|
async with db.DatabaseSession() as session:
|
|
from sqlalchemy import delete
|
|
stmt = delete(db.wandering_enemies).where(db.wandering_enemies.c.id == req.enemy_id)
|
|
await session.execute(stmt)
|
|
await session.commit()
|
|
|
|
# Track combat initiation
|
|
await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True)
|
|
|
|
# Get player info for broadcasts
|
|
player = current_user # current_user is already the character dict
|
|
|
|
# Send WebSocket update to the player
|
|
await manager.send_personal_message(current_user['id'], {
|
|
"type": "combat_started",
|
|
"data": {
|
|
"message": f"Combat started with {npc_def.name}!",
|
|
"combat": {
|
|
"npc_id": enemy.npc_id,
|
|
"npc_name": npc_def.name,
|
|
"npc_hp": npc_hp,
|
|
"npc_max_hp": npc_hp,
|
|
"npc_image": f"{npc_def.image_path}",
|
|
"turn": "player",
|
|
"round": 1
|
|
}
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
|
|
# Broadcast to location that player entered combat
|
|
await manager.send_to_location(
|
|
location_id=current_user['location_id'],
|
|
message={
|
|
"type": "location_update",
|
|
"data": {
|
|
"message": f"{player['name']} entered combat with {npc_def.name}",
|
|
"action": "combat_started",
|
|
"player_id": player['id']
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
},
|
|
exclude_player_id=current_user['id']
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Combat started with {npc_def.name}!",
|
|
"combat": {
|
|
"npc_id": enemy.npc_id,
|
|
"npc_name": npc_def.name,
|
|
"npc_hp": npc_hp,
|
|
"npc_max_hp": npc_hp,
|
|
"npc_image": f"{npc_def.image_path}",
|
|
"turn": "player",
|
|
"round": 1
|
|
}
|
|
}
|
|
|
|
|
|
@router.post("/api/game/combat/action")
|
|
async def combat_action(
|
|
req: CombatActionRequest,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Perform a combat action"""
|
|
import random
|
|
import sys
|
|
sys.path.insert(0, '/app')
|
|
from data.npcs import NPCS
|
|
|
|
# Get active combat
|
|
combat = await db.get_active_combat(current_user['id'])
|
|
if not combat:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Not in combat"
|
|
)
|
|
|
|
if combat['turn'] != 'player':
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Not your turn"
|
|
)
|
|
|
|
# Prevent rapid-fire attacks: Check if enough time has passed since last action
|
|
# This prevents abuse by reloading the page to bypass the 2-second enemy turn delay
|
|
# Skip this check on the first turn (round 1) since player always starts
|
|
import time
|
|
current_round = combat.get('round', 1)
|
|
|
|
if current_round > 1: # Only check after first turn
|
|
turn_started_at = combat.get('turn_started_at', 0)
|
|
time_since_turn_start = time.time() - turn_started_at
|
|
|
|
# If the turn just started (less than 2 seconds ago), it means the enemy just attacked
|
|
# and we should wait for the animation to complete
|
|
if time_since_turn_start < 2.0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Please wait for the enemy's turn to complete"
|
|
)
|
|
|
|
# Get player and NPC data
|
|
player = current_user # current_user is already the character dict
|
|
npc_def = NPCS.get(combat['npc_id'])
|
|
|
|
result_message = ""
|
|
combat_over = False
|
|
player_won = False
|
|
|
|
if req.action == 'attack':
|
|
# Calculate player damage
|
|
base_damage = 5
|
|
strength_bonus = player['strength'] // 2
|
|
level_bonus = player['level']
|
|
weapon_damage = 0
|
|
weapon_effects = {}
|
|
weapon_inv_id = None
|
|
|
|
# Check for equipped weapon
|
|
equipment = await db.get_all_equipment(player['id'])
|
|
if equipment.get('weapon') and equipment['weapon']:
|
|
weapon_slot = equipment['weapon']
|
|
inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id'])
|
|
if inv_item:
|
|
weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
|
if weapon_def and weapon_def.stats:
|
|
weapon_damage = random.randint(
|
|
weapon_def.stats.get('damage_min', 0),
|
|
weapon_def.stats.get('damage_max', 0)
|
|
)
|
|
weapon_effects = weapon_def.weapon_effects if hasattr(weapon_def, 'weapon_effects') else {}
|
|
weapon_inv_id = weapon_slot['item_id']
|
|
|
|
# Check encumbrance penalty (higher encumbrance = chance to miss)
|
|
encumbrance = player.get('encumbrance', 0)
|
|
attack_failed = False
|
|
if encumbrance > 0:
|
|
miss_chance = min(0.3, encumbrance * 0.05) # Max 30% miss chance
|
|
if random.random() < miss_chance:
|
|
attack_failed = True
|
|
|
|
variance = random.randint(-2, 2)
|
|
damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
|
|
|
if attack_failed:
|
|
result_message = f"Your attack misses due to heavy encumbrance! "
|
|
new_npc_hp = combat['npc_hp']
|
|
else:
|
|
# Apply damage to NPC
|
|
new_npc_hp = max(0, combat['npc_hp'] - damage)
|
|
result_message = f"You attack for {damage} damage! "
|
|
|
|
# Apply weapon effects
|
|
if weapon_effects and 'bleeding' in weapon_effects:
|
|
bleeding = weapon_effects['bleeding']
|
|
if random.random() < bleeding.get('chance', 0):
|
|
# Apply bleeding effect (would need combat effects table, for now just bonus damage)
|
|
bleed_damage = bleeding.get('damage', 0)
|
|
new_npc_hp = max(0, new_npc_hp - bleed_damage)
|
|
result_message += f"💉 Bleeding effect! +{bleed_damage} damage! "
|
|
|
|
# Decrease weapon durability (from unique_item)
|
|
if weapon_inv_id and inv_item.get('unique_item_id'):
|
|
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
|
|
if new_durability is None:
|
|
# Weapon broke (unique_item was deleted, cascades to inventory)
|
|
result_message += "\n⚠️ Your weapon broke! "
|
|
await db.unequip_item(player['id'], 'weapon')
|
|
|
|
if new_npc_hp <= 0:
|
|
# NPC defeated
|
|
result_message += f"{npc_def.name} has been defeated!"
|
|
combat_over = True
|
|
player_won = True
|
|
|
|
# Award XP
|
|
xp_gained = npc_def.xp_reward
|
|
new_xp = player['xp'] + xp_gained
|
|
result_message += f"\n+{xp_gained} XP"
|
|
|
|
await db.update_player(player['id'], xp=new_xp)
|
|
|
|
# Track kill statistics
|
|
await db.update_player_statistics(player['id'], enemies_killed=1, damage_dealt=damage, increment=True)
|
|
|
|
# Check for level up
|
|
level_up_result = await game_logic.check_and_apply_level_up(player['id'])
|
|
if level_up_result['leveled_up']:
|
|
result_message += f"\n🎉 Level Up! You are now level {level_up_result['new_level']}!"
|
|
result_message += f"\n+{level_up_result['levels_gained']} stat point(s) to spend!"
|
|
|
|
# Create corpse with loot
|
|
import json
|
|
corpse_loot = npc_def.corpse_loot if hasattr(npc_def, 'corpse_loot') else []
|
|
# Convert CorpseLoot objects to dicts
|
|
corpse_loot_dicts = []
|
|
for loot in corpse_loot:
|
|
if hasattr(loot, '__dict__'):
|
|
corpse_loot_dicts.append({
|
|
'item_id': loot.item_id,
|
|
'quantity_min': loot.quantity_min,
|
|
'quantity_max': loot.quantity_max,
|
|
'required_tool': loot.required_tool
|
|
})
|
|
else:
|
|
corpse_loot_dicts.append(loot)
|
|
await db.create_npc_corpse(
|
|
npc_id=combat['npc_id'],
|
|
location_id=player['location_id'],
|
|
loot_remaining=json.dumps(corpse_loot_dicts)
|
|
)
|
|
|
|
await db.end_combat(player['id'])
|
|
|
|
# Update Redis: Delete combat state cache
|
|
if redis_manager:
|
|
await redis_manager.delete_combat_state(player['id'])
|
|
# Update player session
|
|
await redis_manager.update_player_session_field(player['id'], 'xp', new_xp)
|
|
if level_up_result['leveled_up']:
|
|
await redis_manager.update_player_session_field(player['id'], 'level', level_up_result['new_level'])
|
|
|
|
# Broadcast to location that combat ended and corpse appeared
|
|
await manager.send_to_location(
|
|
location_id=player['location_id'],
|
|
message={
|
|
"type": "location_update",
|
|
"data": {
|
|
"message": f"{player['name']} defeated {npc_def.name}",
|
|
"action": "combat_ended",
|
|
"player_id": player['id'],
|
|
"corpse_created": True
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
},
|
|
exclude_player_id=player['id']
|
|
)
|
|
|
|
else:
|
|
# NPC's turn - use shared logic
|
|
npc_attack_message, player_defeated = await game_logic.npc_attack(
|
|
player['id'],
|
|
{'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp']},
|
|
npc_def,
|
|
reduce_armor_durability
|
|
)
|
|
result_message += f"\n{npc_attack_message}"
|
|
|
|
if player_defeated:
|
|
combat_over = True
|
|
else:
|
|
# Update NPC HP (combat turn already updated by npc_attack)
|
|
await db.update_combat(player['id'], {
|
|
'npc_hp': new_npc_hp
|
|
})
|
|
|
|
elif req.action == 'flee':
|
|
# 50% chance to flee
|
|
if random.random() < 0.5:
|
|
result_message = "You successfully fled from combat!"
|
|
combat_over = True
|
|
player_won = False # Fled, not won
|
|
|
|
# Track successful flee
|
|
await db.update_player_statistics(player['id'], successful_flees=1, increment=True)
|
|
|
|
# Respawn the enemy back to the location if it came from wandering
|
|
if combat.get('from_wandering_enemy'):
|
|
# Respawn enemy with current HP at the combat location
|
|
import time
|
|
despawn_time = time.time() + 300 # 5 minutes
|
|
async with db.DatabaseSession() as session:
|
|
from sqlalchemy import insert
|
|
stmt = insert(db.wandering_enemies).values(
|
|
npc_id=combat['npc_id'],
|
|
location_id=combat['location_id'],
|
|
spawn_timestamp=time.time(),
|
|
despawn_timestamp=despawn_time
|
|
)
|
|
await session.execute(stmt)
|
|
await session.commit()
|
|
|
|
await db.end_combat(player['id'])
|
|
|
|
# Broadcast to location that player fled from combat
|
|
await manager.send_to_location(
|
|
location_id=combat['location_id'],
|
|
message={
|
|
"type": "location_update",
|
|
"data": {
|
|
"message": f"{player['name']} fled from combat",
|
|
"action": "combat_fled",
|
|
"player_id": player['id']
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
},
|
|
exclude_player_id=player['id']
|
|
)
|
|
else:
|
|
# Failed to flee, NPC attacks
|
|
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
|
new_player_hp = max(0, player['hp'] - npc_damage)
|
|
result_message = f"Failed to flee! {npc_def.name} attacks for {npc_damage} damage!"
|
|
|
|
if new_player_hp <= 0:
|
|
result_message += "\nYou have been defeated!"
|
|
combat_over = True
|
|
await db.update_player(player['id'], hp=0, is_dead=True)
|
|
await db.update_player_statistics(player['id'], deaths=1, failed_flees=1, damage_taken=npc_damage, increment=True)
|
|
|
|
# Create corpse with player's inventory
|
|
import json
|
|
import time as time_module
|
|
inventory = await db.get_inventory(player['id'])
|
|
inventory_items = []
|
|
for inv_item in inventory:
|
|
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
|
inventory_items.append({
|
|
'item_id': inv_item['item_id'],
|
|
'name': item_def.name if item_def else inv_item['item_id'],
|
|
'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '📦',
|
|
'quantity': inv_item['quantity'],
|
|
'durability': inv_item.get('durability'),
|
|
'max_durability': inv_item.get('max_durability'),
|
|
'tier': inv_item.get('tier')
|
|
})
|
|
|
|
logger.info(f"Creating player corpse (failed flee) for {player['name']} at {combat['location_id']} with {len(inventory_items)} items")
|
|
|
|
corpse_id = await db.create_player_corpse(
|
|
player_name=player['name'],
|
|
location_id=combat['location_id'],
|
|
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
|
|
)
|
|
|
|
logger.info(f"Successfully created player corpse (failed flee): ID={corpse_id}, player={player['name']}, location={combat['location_id']}, items_count={len(inventory_items)}")
|
|
|
|
# Clear player's inventory (items are now in corpse)
|
|
await db.clear_inventory(player['id'])
|
|
|
|
# Build corpse data for broadcast
|
|
corpse_data = {
|
|
"id": f"player_{corpse_id}",
|
|
"type": "player",
|
|
"name": f"{player['name']}'s Corpse",
|
|
"emoji": "⚰️",
|
|
"player_name": player['name'],
|
|
"loot_count": len(inventory_items),
|
|
"items": inventory_items,
|
|
"timestamp": time_module.time()
|
|
}
|
|
|
|
# Respawn enemy if from wandering
|
|
if combat.get('from_wandering_enemy'):
|
|
import time
|
|
despawn_time = time.time() + 300
|
|
async with db.DatabaseSession() as session:
|
|
from sqlalchemy import insert
|
|
stmt = insert(db.wandering_enemies).values(
|
|
npc_id=combat['npc_id'],
|
|
location_id=combat['location_id'],
|
|
spawn_timestamp=time.time(),
|
|
despawn_timestamp=despawn_time
|
|
)
|
|
await session.execute(stmt)
|
|
await session.commit()
|
|
|
|
await db.end_combat(player['id'])
|
|
|
|
# Broadcast to location that player died and corpse appeared
|
|
logger.info(f"Broadcasting player_died (failed flee) to location {combat['location_id']} for player {player['name']}")
|
|
await manager.send_to_location(
|
|
location_id=combat['location_id'],
|
|
message={
|
|
"type": "location_update",
|
|
"data": {
|
|
"message": f"{player['name']} was defeated in combat",
|
|
"action": "player_died",
|
|
"player_id": player['id'],
|
|
"corpse": corpse_data
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
},
|
|
exclude_player_id=player['id']
|
|
)
|
|
else:
|
|
# Player survived, update HP and turn back to player
|
|
await db.update_player(player['id'], hp=new_player_hp)
|
|
await db.update_player_statistics(player['id'], failed_flees=1, damage_taken=npc_damage, increment=True)
|
|
await db.update_combat(player['id'], {'turn': 'player', 'turn_started_at': time.time()})
|
|
|
|
# Get updated combat state if not over
|
|
updated_combat = None
|
|
if not combat_over:
|
|
raw_combat = await db.get_active_combat(current_user['id'])
|
|
if raw_combat:
|
|
# Calculate time remaining for turn
|
|
import time
|
|
turn_time_remaining = None
|
|
if raw_combat['turn'] == 'player':
|
|
turn_started_at = raw_combat.get('turn_started_at', 0)
|
|
time_elapsed = time.time() - turn_started_at
|
|
turn_time_remaining = max(0, 300 - time_elapsed)
|
|
|
|
updated_combat = {
|
|
"npc_id": raw_combat['npc_id'],
|
|
"npc_name": npc_def.name,
|
|
"npc_hp": raw_combat['npc_hp'],
|
|
"npc_max_hp": raw_combat['npc_max_hp'],
|
|
"npc_image": f"{npc_def.image_path}",
|
|
"turn": raw_combat['turn'],
|
|
"round": raw_combat.get('round', 1),
|
|
"turn_time_remaining": turn_time_remaining
|
|
}
|
|
|
|
# Get fresh player data with updated HP after NPC attack
|
|
updated_player = await db.get_player_by_id(current_user['id'])
|
|
if not updated_player:
|
|
updated_player = current_user # Fallback to current_user if something went wrong
|
|
|
|
# Note: PvE combat_update WebSocket removed - frontend uses client-side timer
|
|
# and polling for AFK timeout handling. WebSocket still used for PvP.
|
|
|
|
return {
|
|
"success": True,
|
|
"message": result_message,
|
|
"combat_over": combat_over,
|
|
"player_won": player_won if combat_over else None,
|
|
"combat": updated_combat if updated_combat else None,
|
|
"player": {
|
|
"hp": updated_player['hp'],
|
|
"max_hp": updated_player.get('max_hp', updated_player.get('max_health')),
|
|
"xp": updated_player['xp'],
|
|
"level": updated_player['level']
|
|
}
|
|
}
|
|
|
|
|
|
@router.post("/api/game/pvp/initiate")
|
|
async def initiate_pvp_combat(
|
|
req: PvPCombatInitiateRequest,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Initiate PvP combat with another player"""
|
|
# Get attacker (current user)
|
|
attacker = await db.get_player_by_id(current_user['id'])
|
|
if not attacker:
|
|
raise HTTPException(status_code=404, detail="Player not found")
|
|
|
|
# Check if attacker is already in combat
|
|
existing_combat = await db.get_active_combat(attacker['id'])
|
|
if existing_combat:
|
|
raise HTTPException(status_code=400, detail="You are already in PvE combat")
|
|
|
|
existing_pvp = await db.get_pvp_combat_by_player(attacker['id'])
|
|
if existing_pvp:
|
|
raise HTTPException(status_code=400, detail="You are already in PvP combat")
|
|
|
|
# Get defender (target player)
|
|
defender = await db.get_player_by_id(req.target_player_id)
|
|
if not defender:
|
|
raise HTTPException(status_code=404, detail="Target player not found")
|
|
|
|
# Check if defender is in combat
|
|
defender_pve = await db.get_active_combat(defender['id'])
|
|
if defender_pve:
|
|
raise HTTPException(status_code=400, detail="Target player is in PvE combat")
|
|
|
|
defender_pvp = await db.get_pvp_combat_by_player(defender['id'])
|
|
if defender_pvp:
|
|
raise HTTPException(status_code=400, detail="Target player is in PvP combat")
|
|
|
|
# Check same location
|
|
if attacker['location_id'] != defender['location_id']:
|
|
raise HTTPException(status_code=400, detail="Target player is not in your location")
|
|
|
|
# Check danger level (>= 3 required for PvP)
|
|
location = LOCATIONS.get(attacker['location_id'])
|
|
if not location or location.danger_level < 3:
|
|
raise HTTPException(status_code=400, detail="PvP combat is only allowed in dangerous zones (danger level >= 3)")
|
|
|
|
# Check level difference (+/- 3 levels)
|
|
level_diff = abs(attacker['level'] - defender['level'])
|
|
if level_diff > 3:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Level difference too large! You can only fight players within 3 levels (target is level {defender['level']})"
|
|
)
|
|
|
|
# Create PvP combat
|
|
pvp_combat = await db.create_pvp_combat(
|
|
attacker_id=attacker['id'],
|
|
defender_id=defender['id'],
|
|
location_id=attacker['location_id'],
|
|
turn_timeout=300 # 5 minutes
|
|
)
|
|
|
|
# Track PvP combat initiation
|
|
await db.update_player_statistics(attacker['id'], pvp_combats_initiated=1, increment=True)
|
|
|
|
# Send WebSocket notifications to both players
|
|
await manager.send_personal_message(attacker['id'], {
|
|
"type": "combat_started",
|
|
"data": {
|
|
"message": f"You have initiated combat with {defender['name']}! They get the first turn.",
|
|
"pvp_combat": pvp_combat
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
|
|
await manager.send_personal_message(defender['id'], {
|
|
"type": "combat_started",
|
|
"data": {
|
|
"message": f"{attacker['name']} has challenged you to PvP combat! It's your turn.",
|
|
"pvp_combat": pvp_combat
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"You have initiated combat with {defender['name']}! They get the first turn.",
|
|
"pvp_combat": pvp_combat
|
|
}
|
|
|
|
|
|
@router.get("/api/game/pvp/status")
|
|
async def get_pvp_combat_status(current_user: dict = Depends(get_current_user)):
|
|
"""Get current PvP combat status"""
|
|
pvp_combat = await db.get_pvp_combat_by_player(current_user['id'])
|
|
if not pvp_combat:
|
|
return {"in_pvp_combat": False, "pvp_combat": None}
|
|
|
|
# Check if current player has already acknowledged - if so, don't show combat anymore
|
|
is_attacker = pvp_combat['attacker_character_id'] == current_user['id']
|
|
if (is_attacker and pvp_combat.get('attacker_acknowledged', False)) or \
|
|
(not is_attacker and pvp_combat.get('defender_acknowledged', False)):
|
|
return {"in_pvp_combat": False, "pvp_combat": None}
|
|
|
|
# Get both players' data
|
|
attacker = await db.get_player_by_id(pvp_combat['attacker_character_id'])
|
|
defender = await db.get_player_by_id(pvp_combat['defender_character_id'])
|
|
|
|
# Determine if current user is attacker or defender
|
|
is_attacker = pvp_combat['attacker_character_id'] == current_user['id']
|
|
your_turn = (is_attacker and pvp_combat['turn'] == 'attacker') or \
|
|
(not is_attacker and pvp_combat['turn'] == 'defender')
|
|
|
|
# Calculate time remaining for turn
|
|
import time
|
|
time_elapsed = time.time() - pvp_combat['turn_started_at']
|
|
time_remaining = max(0, pvp_combat['turn_timeout_seconds'] - time_elapsed)
|
|
|
|
# Auto-advance if time expired
|
|
if time_remaining == 0 and your_turn:
|
|
# Skip turn
|
|
new_turn = 'defender' if is_attacker else 'attacker'
|
|
await db.update_pvp_combat(pvp_combat['id'], {
|
|
'turn': new_turn,
|
|
'turn_started_at': time.time()
|
|
})
|
|
pvp_combat = await db.get_pvp_combat_by_id(pvp_combat['id'])
|
|
your_turn = False
|
|
time_remaining = pvp_combat['turn_timeout_seconds']
|
|
|
|
return {
|
|
"in_pvp_combat": True,
|
|
"pvp_combat": {
|
|
"id": pvp_combat['id'],
|
|
"attacker": {
|
|
"id": attacker['id'],
|
|
"username": attacker['name'],
|
|
"level": attacker['level'],
|
|
"hp": attacker['hp'], # Use actual player HP
|
|
"max_hp": attacker['max_hp']
|
|
},
|
|
"defender": {
|
|
"id": defender['id'],
|
|
"username": defender['name'],
|
|
"level": defender['level'],
|
|
"hp": defender['hp'], # Use actual player HP
|
|
"max_hp": defender['max_hp']
|
|
},
|
|
"is_attacker": is_attacker,
|
|
"your_turn": your_turn,
|
|
"current_turn": pvp_combat['turn'],
|
|
"time_remaining": int(time_remaining),
|
|
"location_id": pvp_combat['location_id'],
|
|
"last_action": pvp_combat.get('last_action'),
|
|
"combat_over": pvp_combat.get('attacker_fled', False) or pvp_combat.get('defender_fled', False) or \
|
|
attacker['hp'] <= 0 or defender['hp'] <= 0,
|
|
"attacker_fled": pvp_combat.get('attacker_fled', False),
|
|
"defender_fled": pvp_combat.get('defender_fled', False)
|
|
}
|
|
}
|
|
|
|
|
|
class PvPAcknowledgeRequest(BaseModel):
|
|
combat_id: int
|
|
|
|
|
|
@router.post("/api/game/pvp/acknowledge")
|
|
async def acknowledge_pvp_combat(
|
|
req: PvPAcknowledgeRequest,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Acknowledge PvP combat end"""
|
|
await db.acknowledge_pvp_combat(req.combat_id, current_user['id'])
|
|
|
|
# Broadcast to location that player has returned
|
|
player = current_user # current_user is already the character dict
|
|
if player:
|
|
await manager.send_to_location(
|
|
location_id=player['location_id'],
|
|
message={
|
|
"type": "player_arrived",
|
|
"data": {
|
|
"player_id": player['id'],
|
|
"username": player['name'],
|
|
"message": f"{player['name']} has returned from PvP combat."
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
},
|
|
exclude_player_id=player['id']
|
|
)
|
|
|
|
return {"success": True}
|
|
|
|
|
|
class PvPCombatActionRequest(BaseModel):
|
|
action: str # 'attack', 'flee', 'use_item'
|
|
item_id: Optional[str] = None # For use_item action
|
|
|
|
|
|
@router.post("/api/game/pvp/action")
|
|
async def pvp_combat_action(
|
|
req: PvPCombatActionRequest,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Perform a PvP combat action"""
|
|
import random
|
|
import time
|
|
|
|
# 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
|
|
|
|
result_message = ""
|
|
combat_over = False
|
|
winner_id = None
|
|
|
|
if req.action == 'attack':
|
|
# Calculate damage (similar to PvE)
|
|
base_damage = 5
|
|
strength_bonus = current_player['strength'] * 2
|
|
level_bonus = current_player['level']
|
|
|
|
# Check for equipped weapon
|
|
weapon_damage = 0
|
|
equipment = await db.get_all_equipment(current_player['id'])
|
|
if equipment.get('weapon') and equipment['weapon']:
|
|
weapon_slot = equipment['weapon']
|
|
inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id'])
|
|
if inv_item:
|
|
weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
|
if weapon_def and weapon_def.stats:
|
|
weapon_damage = random.randint(
|
|
weapon_def.stats.get('damage_min', 0),
|
|
weapon_def.stats.get('damage_max', 0)
|
|
)
|
|
# Decrease weapon durability
|
|
if inv_item.get('unique_item_id'):
|
|
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
|
|
if new_durability is None:
|
|
result_message += "⚠️ Your weapon broke! "
|
|
await db.unequip_item(current_player['id'], 'weapon')
|
|
|
|
variance = random.randint(-2, 2)
|
|
damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
|
|
|
# Apply armor reduction and durability loss to opponent
|
|
armor_absorbed, broken_armor = await reduce_armor_durability(opponent['id'], damage)
|
|
actual_damage = max(1, damage - armor_absorbed)
|
|
|
|
# Update opponent HP (use actual player HP, not pvp_combat fields)
|
|
new_opponent_hp = max(0, opponent['hp'] - actual_damage)
|
|
|
|
# Update opponent's HP in database
|
|
await db.update_player(opponent['id'], hp=new_opponent_hp)
|
|
|
|
# Store message with attacker's username so both players can see it correctly
|
|
stored_message = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!"
|
|
if armor_absorbed > 0:
|
|
stored_message += f" (Armor absorbed {armor_absorbed})"
|
|
|
|
for broken in broken_armor:
|
|
stored_message += f"\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!"
|
|
|
|
# Check if opponent defeated
|
|
if new_opponent_hp <= 0:
|
|
stored_message += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!"
|
|
result_message = "Combat victory!" # Simple message, details in stored_message
|
|
combat_over = True
|
|
winner_id = current_player['id']
|
|
|
|
# Update opponent to dead state
|
|
await db.update_player(opponent['id'], hp=0, is_dead=True)
|
|
|
|
# Create corpse with opponent's inventory
|
|
import json
|
|
import time as time_module
|
|
inventory = await db.get_inventory(opponent['id'])
|
|
inventory_items = []
|
|
for inv_item in inventory:
|
|
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
|
inventory_items.append({
|
|
'item_id': inv_item['item_id'],
|
|
'name': item_def.name if item_def else inv_item['item_id'],
|
|
'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '📦',
|
|
'quantity': inv_item['quantity'],
|
|
'durability': inv_item.get('durability'),
|
|
'max_durability': inv_item.get('max_durability'),
|
|
'tier': inv_item.get('tier')
|
|
})
|
|
|
|
logger.info(f"Creating player corpse (PvP death) for {opponent['name']} at {opponent['location_id']} with {len(inventory_items)} items")
|
|
|
|
corpse_id = await db.create_player_corpse(
|
|
player_name=opponent['name'],
|
|
location_id=opponent['location_id'],
|
|
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
|
|
)
|
|
|
|
logger.info(f"Successfully created player corpse (PvP death): ID={corpse_id}, player={opponent['name']}, location={opponent['location_id']}, items_count={len(inventory_items)}")
|
|
|
|
# Clear opponent's inventory (items are now in corpse)
|
|
await db.clear_inventory(opponent['id'])
|
|
|
|
# Build corpse data for broadcast
|
|
corpse_data = {
|
|
"id": f"player_{corpse_id}",
|
|
"type": "player",
|
|
"name": f"{opponent['name']}'s Corpse",
|
|
"emoji": "⚰️",
|
|
"player_name": opponent['name'],
|
|
"loot_count": len(inventory_items),
|
|
"items": inventory_items,
|
|
"timestamp": time_module.time()
|
|
}
|
|
|
|
# Update PvP statistics for both players
|
|
await db.update_player_statistics(opponent['id'],
|
|
pvp_deaths=1,
|
|
pvp_combats_lost=1,
|
|
pvp_damage_taken=actual_damage,
|
|
pvp_attacks_received=1,
|
|
increment=True
|
|
)
|
|
await db.update_player_statistics(current_player['id'],
|
|
players_killed=1,
|
|
pvp_combats_won=1,
|
|
pvp_damage_dealt=damage,
|
|
pvp_attacks_landed=1,
|
|
increment=True
|
|
)
|
|
|
|
# Broadcast to location that player died and corpse appeared
|
|
logger.info(f"Broadcasting player_died (PvP death) to location {opponent['location_id']} for player {opponent['name']}")
|
|
await manager.send_to_location(
|
|
location_id=opponent['location_id'],
|
|
message={
|
|
"type": "location_update",
|
|
"data": {
|
|
"message": f"{opponent['name']} was defeated by {current_player['name']} in PvP combat",
|
|
"action": "player_died",
|
|
"player_id": opponent['id'],
|
|
"corpse": corpse_data
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
}
|
|
)
|
|
|
|
# End PvP combat
|
|
await db.end_pvp_combat(pvp_combat['id'])
|
|
else:
|
|
# Combat continues - don't return detailed message, it's in stored_message
|
|
result_message = "" # Empty message, frontend will show stored_message from polling
|
|
|
|
# Update PvP statistics for attack
|
|
await db.update_player_statistics(current_player['id'],
|
|
pvp_damage_dealt=damage,
|
|
pvp_attacks_landed=1,
|
|
increment=True
|
|
)
|
|
await db.update_player_statistics(opponent['id'],
|
|
pvp_damage_taken=actual_damage,
|
|
pvp_attacks_received=1,
|
|
increment=True
|
|
)
|
|
|
|
# Update combat state and switch turns
|
|
# Add timestamp to make each action unique for duplicate detection
|
|
updates = {
|
|
'turn': 'defender' if is_attacker else 'attacker',
|
|
'turn_started_at': time.time(),
|
|
'last_action': f"{stored_message}|{time.time()}" # Add timestamp for uniqueness
|
|
}
|
|
# No need to update HP in pvp_combat - we use player HP directly
|
|
|
|
await db.update_pvp_combat(pvp_combat['id'], updates)
|
|
await db.update_player_statistics(current_player['id'], damage_dealt=damage, increment=True)
|
|
|
|
elif req.action == 'flee':
|
|
# 50% chance to flee from PvP
|
|
if random.random() < 0.5:
|
|
result_message = f"You successfully fled from {opponent['name']}!"
|
|
combat_over = True
|
|
|
|
# Mark as fled, store last action with timestamp, and end combat
|
|
flee_field = 'attacker_fled' if is_attacker else 'defender_fled'
|
|
await db.update_pvp_combat(pvp_combat['id'], {
|
|
flee_field: True,
|
|
'last_action': f"{current_player['name']} fled from combat!|{time.time()}"
|
|
})
|
|
await db.end_pvp_combat(pvp_combat['id'])
|
|
await db.update_player_statistics(current_player['id'],
|
|
pvp_successful_flees=1,
|
|
increment=True
|
|
)
|
|
else:
|
|
# Failed to flee, skip turn
|
|
result_message = f"Failed to flee from {opponent['name']}!"
|
|
await db.update_pvp_combat(pvp_combat['id'], {
|
|
'turn': 'defender' if is_attacker else 'attacker',
|
|
'turn_started_at': time.time(),
|
|
'last_action': f"{current_player['name']} tried to flee but failed!|{time.time()}"
|
|
})
|
|
await db.update_player_statistics(current_player['id'],
|
|
pvp_failed_flees=1,
|
|
increment=True
|
|
)
|
|
|
|
# Send WebSocket combat updates to both players
|
|
# Get fresh PvP combat data
|
|
updated_pvp = await db.get_pvp_combat_by_id(pvp_combat['id'])
|
|
|
|
# Get fresh player data for HP updates
|
|
fresh_attacker = await db.get_player_by_id(pvp_combat['attacker_character_id'])
|
|
fresh_defender = await db.get_player_by_id(pvp_combat['defender_character_id'])
|
|
|
|
# Send to both players with enriched data (like the API endpoint does)
|
|
for player_id in [pvp_combat['attacker_character_id'], pvp_combat['defender_character_id']]:
|
|
is_attacker = player_id == pvp_combat['attacker_character_id']
|
|
your_turn = (is_attacker and updated_pvp['turn'] == 'attacker') or \
|
|
(not is_attacker and updated_pvp['turn'] == 'defender')
|
|
|
|
# Calculate time remaining
|
|
import time
|
|
time_elapsed = time.time() - updated_pvp['turn_started_at']
|
|
time_remaining = max(0, updated_pvp['turn_timeout_seconds'] - time_elapsed)
|
|
|
|
# Build enriched pvp_combat object like the API does
|
|
enriched_pvp = {
|
|
"id": updated_pvp['id'],
|
|
"attacker": {
|
|
"id": fresh_attacker['id'],
|
|
"username": fresh_attacker['name'],
|
|
"level": fresh_attacker['level'],
|
|
"hp": fresh_attacker['hp'],
|
|
"max_hp": fresh_attacker['max_hp']
|
|
},
|
|
"defender": {
|
|
"id": fresh_defender['id'],
|
|
"username": fresh_defender['name'],
|
|
"level": fresh_defender['level'],
|
|
"hp": fresh_defender['hp'],
|
|
"max_hp": fresh_defender['max_hp']
|
|
},
|
|
"is_attacker": is_attacker,
|
|
"your_turn": your_turn,
|
|
"current_turn": updated_pvp['turn'],
|
|
"time_remaining": int(time_remaining),
|
|
"location_id": updated_pvp['location_id'],
|
|
"last_action": updated_pvp.get('last_action'),
|
|
"combat_over": updated_pvp.get('attacker_fled', False) or updated_pvp.get('defender_fled', False) or \
|
|
fresh_attacker['hp'] <= 0 or fresh_defender['hp'] <= 0,
|
|
"attacker_fled": updated_pvp.get('attacker_fled', False),
|
|
"defender_fled": updated_pvp.get('defender_fled', False)
|
|
}
|
|
|
|
await manager.send_personal_message(player_id, {
|
|
"type": "combat_update",
|
|
"data": {
|
|
"message": result_message if player_id == current_user['id'] else "",
|
|
"log_entry": result_message if player_id == current_user['id'] else "", # Append to combat log
|
|
"pvp_combat": enriched_pvp,
|
|
"combat_over": combat_over,
|
|
"winner_id": winner_id,
|
|
"attacker_hp": fresh_attacker['hp'],
|
|
"defender_hp": fresh_defender['hp']
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"message": result_message,
|
|
"combat_over": combat_over,
|
|
"winner_id": winner_id
|
|
} |