2820 lines
123 KiB
Python
2820 lines
123 KiB
Python
"""
|
|
Combat router.
|
|
Auto-generated from main.py migration.
|
|
"""
|
|
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 logging
|
|
from ..services.constants import PVP_TURN_TIMEOUT
|
|
|
|
from ..core.security import get_current_user, security, verify_internal_key
|
|
from ..services.models import *
|
|
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message, get_game_message
|
|
from .. import database as db
|
|
from ..items import ItemsManager
|
|
from .. import game_logic
|
|
from ..core.websockets import manager
|
|
from .equipment import reduce_armor_durability
|
|
|
|
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"])
|
|
|
|
|
|
|
|
# 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,
|
|
request: Request,
|
|
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
|
|
|
|
# Extract locale from Accept-Language header
|
|
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"
|
|
)
|
|
|
|
# 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": {
|
|
"messages": [create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name)],
|
|
"combat": {
|
|
"npc_id": enemy.npc_id,
|
|
"npc_name": npc_def.name,
|
|
"npc_hp": npc_hp,
|
|
"npc_max_hp": npc_hp,
|
|
"npc_image": f"{npc_def.image_path}",
|
|
"turn": "player",
|
|
"round": 1
|
|
}
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
|
|
# Broadcast to location that player entered combat
|
|
await manager.send_to_location(
|
|
location_id=current_user['location_id'],
|
|
message={
|
|
"type": "location_update",
|
|
"data": {
|
|
"message": get_game_message('player_entered_combat', locale, player_name=player['name'], npc_name=get_locale_string(npc_def.name, locale)),
|
|
"action": "combat_started",
|
|
"player_id": player['id']
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
},
|
|
exclude_player_id=current_user['id']
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"messages": [create_combat_message("combat_start", origin="neutral", npc_name=npc_def.name)],
|
|
"combat": {
|
|
"npc_id": enemy.npc_id,
|
|
"npc_name": npc_def.name,
|
|
"npc_hp": npc_hp,
|
|
"npc_max_hp": npc_hp,
|
|
"npc_image": f"{npc_def.image_path}",
|
|
"turn": "player",
|
|
"round": 1
|
|
}
|
|
}
|
|
|
|
|
|
@router.post("/api/game/combat/action")
|
|
async def combat_action(
|
|
req: CombatActionRequest,
|
|
request: Request,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Perform a combat action"""
|
|
import random
|
|
import sys
|
|
sys.path.insert(0, '/app')
|
|
from data.npcs import NPCS
|
|
|
|
# Extract locale from Accept-Language header
|
|
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"
|
|
)
|
|
|
|
# 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'])
|
|
|
|
# Get player derived stats
|
|
from ..services.stats import calculate_derived_stats
|
|
stats = await calculate_derived_stats(player['id'], redis_manager)
|
|
|
|
messages = []
|
|
combat_over = False
|
|
|
|
# Process status effects (bleeding, etc.) before action
|
|
active_effects = await db.tick_player_effects(player['id'])
|
|
|
|
# Process status effects before action
|
|
if active_effects:
|
|
from ..game_logic import calculate_status_impact
|
|
total_impact = calculate_status_impact(active_effects)
|
|
|
|
if total_impact > 0:
|
|
# DAMAGE
|
|
damage = total_impact
|
|
new_hp = max(0, player['hp'] - damage)
|
|
await db.update_player_hp(player['id'], new_hp)
|
|
player['hp'] = new_hp # Update local reference
|
|
|
|
messages.append(create_combat_message(
|
|
"effect_damage",
|
|
origin="player",
|
|
damage=damage,
|
|
effect_name="status effects"
|
|
))
|
|
|
|
if new_hp <= 0:
|
|
# Player died from effects
|
|
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']
|
|
}
|
|
elif total_impact < 0:
|
|
# HEALING
|
|
heal = abs(total_impact)
|
|
new_hp = min(stats['max_hp'], player['hp'] + heal)
|
|
actual_heal = new_hp - player['hp']
|
|
|
|
if actual_heal > 0:
|
|
await db.update_player_hp(player['id'], new_hp)
|
|
player['hp'] = new_hp
|
|
|
|
messages.append(create_combat_message(
|
|
"effect_heal",
|
|
origin="player",
|
|
heal=actual_heal,
|
|
effect_name="status effects"
|
|
))
|
|
|
|
|
|
|
|
if req.action == 'attack':
|
|
# Calculate player damage using derived stats
|
|
base_damage = stats.get('attack_power', 5)
|
|
weapon_effects = {}
|
|
weapon_inv_id = None
|
|
|
|
# Check for equipped weapon to apply durability loss and effects
|
|
# (Attack power from the weapon is already included in stats['attack_power'])
|
|
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:
|
|
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 + variance)
|
|
|
|
if attack_failed:
|
|
messages.append(create_combat_message(
|
|
"player_miss",
|
|
origin="player",
|
|
reason="encumbrance"
|
|
))
|
|
new_npc_hp = combat['npc_hp']
|
|
else:
|
|
# Check for critical hit
|
|
is_critical = False
|
|
crit_chance = stats.get('crit_chance', 0.05)
|
|
if random.random() < crit_chance:
|
|
is_critical = True
|
|
damage = int(damage * stats.get('crit_damage', 1.5))
|
|
|
|
# Apply NPC defense reduction
|
|
npc_defense = getattr(npc_def, 'defense', 0)
|
|
actual_damage = max(1, damage - npc_defense)
|
|
|
|
# Apply damage to NPC
|
|
new_npc_hp = max(0, combat['npc_hp'] - actual_damage)
|
|
|
|
if is_critical:
|
|
messages.append(create_combat_message(
|
|
"combat_crit",
|
|
origin="player"
|
|
))
|
|
|
|
messages.append(create_combat_message(
|
|
"player_attack",
|
|
origin="player",
|
|
damage=actual_damage,
|
|
armor_absorbed=npc_defense if npc_defense > 0 else 0
|
|
))
|
|
|
|
# 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)
|
|
messages.append(create_combat_message(
|
|
"effect_bleeding",
|
|
origin="player",
|
|
damage=bleed_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)
|
|
messages.append(create_combat_message(
|
|
"weapon_broke",
|
|
origin="player",
|
|
item_name=weapon_def.name
|
|
))
|
|
await db.unequip_item(player['id'], 'weapon')
|
|
|
|
if new_npc_hp <= 0:
|
|
# NPC defeated
|
|
messages.append(create_combat_message(
|
|
"victory",
|
|
origin="neutral",
|
|
npc_name=npc_def.name
|
|
))
|
|
combat_over = True
|
|
player_won = True
|
|
|
|
# Award XP
|
|
xp_gained = npc_def.xp_reward
|
|
new_xp = player['xp'] + xp_gained
|
|
messages.append(create_combat_message(
|
|
"xp_gain",
|
|
origin="player",
|
|
amount=xp_gained
|
|
))
|
|
|
|
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']:
|
|
messages.append(create_combat_message(
|
|
"level_up",
|
|
origin="player",
|
|
level=level_up_result['new_level'],
|
|
stat_points=level_up_result['levels_gained']
|
|
))
|
|
|
|
# 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)
|
|
)
|
|
|
|
# --- UPDATE QUEST PROGRESS ---
|
|
# --- UPDATE QUEST PROGRESS ---
|
|
try:
|
|
# Use global QUESTS_DATA injected dependency
|
|
if QUESTS_DATA:
|
|
active_quests = await db.get_character_quests(player['id'])
|
|
quest_updated = False
|
|
|
|
for q_record in active_quests:
|
|
if q_record['status'] != 'active':
|
|
continue
|
|
|
|
q_def = QUESTS_DATA.get(q_record['quest_id'])
|
|
if not q_def: continue
|
|
|
|
objectives = q_def.get('objectives', [])
|
|
current_progress = q_record.get('progress') or {}
|
|
new_progress = current_progress.copy()
|
|
progres_changed = False
|
|
|
|
for obj in objectives:
|
|
if obj['type'] == 'kill_count' and obj['target'] == combat['npc_id']:
|
|
current_count = current_progress.get(obj['target'], 0)
|
|
if current_count < obj['count']:
|
|
new_progress[obj['target']] = current_count + 1
|
|
progres_changed = True
|
|
|
|
|
|
if progres_changed:
|
|
# Check completion
|
|
all_done = True
|
|
progress_str = ""
|
|
|
|
for obj in objectives:
|
|
target = obj['target']
|
|
req_count = obj['count']
|
|
curr = new_progress.get(target, 0)
|
|
|
|
# Simple check (ignoring items for kill quests for now)
|
|
if obj['type'] == 'kill_count':
|
|
if curr < req_count:
|
|
all_done = False
|
|
# Capture progress string for the notification (if this was the target updated)
|
|
if target == combat['npc_id']:
|
|
progress_str = f" ({curr}/{req_count})"
|
|
elif obj['type'] == 'item_delivery':
|
|
pass
|
|
|
|
await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, 'active')
|
|
|
|
# Notify user
|
|
messages.append(create_combat_message(
|
|
"quest_update",
|
|
origin="system",
|
|
message=f"{get_locale_string(q_def['title'], locale)}{progress_str}"
|
|
))
|
|
quest_updated = True
|
|
|
|
# Add to quest updates list to return to client
|
|
# Filter/Enrich for frontend
|
|
updated_q_data = dict(q_record)
|
|
updated_q_data['start_at'] = q_record['started_at']
|
|
updated_q_data.update(q_def)
|
|
if 'quest_updates' not in locals(): quest_updates = []
|
|
quest_updates.append(updated_q_data)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to update quest progress: {e}")
|
|
# -----------------------------
|
|
# -----------------------------
|
|
|
|
|
|
|
|
await db.remove_non_persistent_effects(player['id'])
|
|
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": get_game_message('player_defeated_enemy_broadcast', locale, player_name=player['name'], npc_name=get_locale_string(npc_def.name, locale)),
|
|
"action": "combat_ended",
|
|
"player_id": player['id'],
|
|
"corpse_created": True
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
},
|
|
exclude_player_id=player['id']
|
|
)
|
|
|
|
else:
|
|
# NPC's turn - use shared logic
|
|
npc_attack_messages, 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
|
|
)
|
|
messages.extend(npc_attack_messages)
|
|
|
|
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 == 'skill':
|
|
if not req.item_id:
|
|
raise HTTPException(status_code=400, detail="skill_id (passed as item_id) required")
|
|
|
|
from ..services.skills import skills_manager
|
|
skill_id = req.item_id
|
|
skill = skills_manager.get_skill(skill_id)
|
|
if not skill:
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
|
|
# Check unlocked
|
|
stat_val = current_player.get(skill.stat_requirement, 0)
|
|
if stat_val < skill.stat_threshold or current_player['level'] < skill.level_requirement:
|
|
raise HTTPException(status_code=400, detail="Skill not unlocked")
|
|
|
|
# Check cooldown
|
|
active_effects = await db.get_player_effects(current_player['id'])
|
|
cd_source = f"cd:{skill.id}"
|
|
for eff in active_effects:
|
|
if eff.get('source') == cd_source and eff.get('ticks_remaining', 0) > 0:
|
|
raise HTTPException(status_code=400, detail=f"Skill on cooldown ({eff['ticks_remaining']} turns)")
|
|
|
|
# Check stamina
|
|
if current_player['stamina'] < skill.stamina_cost:
|
|
raise HTTPException(status_code=400, detail="Not enough stamina")
|
|
|
|
# Deduct stamina
|
|
new_stamina = current_player['stamina'] - skill.stamina_cost
|
|
await db.update_player_stamina(current_player['id'], new_stamina)
|
|
current_player['stamina'] = new_stamina
|
|
|
|
# Add cooldown effect
|
|
if skill.cooldown > 0:
|
|
await db.add_effect(
|
|
player_id=current_player['id'],
|
|
effect_name=f"{skill.id}_cooldown",
|
|
effect_icon="⏳",
|
|
effect_type="cooldown",
|
|
value=0,
|
|
ticks_remaining=skill.cooldown,
|
|
persist_after_combat=False,
|
|
source=cd_source
|
|
)
|
|
|
|
# Get weapon info
|
|
equipment = await db.get_all_equipment(current_player['id'])
|
|
weapon_damage = 0
|
|
weapon_inv_id = None
|
|
inv_item = None
|
|
weapon_def = None
|
|
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_inv_id = inv_item['id']
|
|
|
|
effects = skill.effects
|
|
new_opponent_hp = opponent['hp']
|
|
damage_done = 0
|
|
actual_damage = 0
|
|
armor_absorbed = 0
|
|
|
|
# Damage skills
|
|
if 'damage_multiplier' in effects:
|
|
base_damage = 5
|
|
strength_bonus = int(current_player['strength'] * 1.5)
|
|
level_bonus = current_player['level']
|
|
variance = random.randint(-2, 2)
|
|
raw_damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
|
|
|
multiplier = effects['damage_multiplier']
|
|
|
|
if 'execute_threshold' in effects:
|
|
opponent_hp_pct = opponent['hp'] / opponent['max_hp'] if opponent['max_hp'] > 0 else 1
|
|
if opponent_hp_pct <= effects['execute_threshold']:
|
|
multiplier = effects.get('execute_multiplier', multiplier)
|
|
|
|
damage = max(1, int(raw_damage * multiplier))
|
|
if effects.get('guaranteed_crit'):
|
|
damage = int(damage * 1.5)
|
|
|
|
num_hits = effects.get('hits', 1)
|
|
|
|
for hit in range(num_hits):
|
|
hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier))
|
|
absorbed, broken_armor = await reduce_armor_durability(opponent['id'], hit_dmg)
|
|
armor_absorbed += absorbed
|
|
|
|
for broken in broken_armor:
|
|
messages.append(create_combat_message(
|
|
"item_broken",
|
|
origin="enemy",
|
|
item_name=broken['name'],
|
|
emoji=broken['emoji']
|
|
))
|
|
last_action_text += f"\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!"
|
|
|
|
actual_hit = max(1, hit_dmg - absorbed)
|
|
damage_done += actual_hit
|
|
new_opponent_hp = max(0, new_opponent_hp - actual_hit)
|
|
|
|
actual_damage = damage_done
|
|
|
|
messages.append(create_combat_message(
|
|
"skill_attack",
|
|
origin="player",
|
|
damage=damage_done,
|
|
skill_name=skill.name,
|
|
skill_icon=skill.icon,
|
|
hits=num_hits
|
|
))
|
|
last_action_text = f"{current_player['name']} used {skill.name} on {opponent['name']} for {damage_done} damage! (Armor absorbed {armor_absorbed})"
|
|
|
|
# Lifesteal
|
|
if 'lifesteal' in effects:
|
|
heal_amount = int(damage_done * effects['lifesteal'])
|
|
new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount)
|
|
if new_hp > current_player['hp']:
|
|
await db.update_player(current_player['id'], hp=new_hp)
|
|
current_player['hp'] = new_hp
|
|
messages.append(create_combat_message(
|
|
"skill_heal", origin="player", heal=heal_amount, skill_icon="🩸"
|
|
))
|
|
|
|
# Poison DoT
|
|
if 'poison_damage' in effects:
|
|
await db.add_effect(
|
|
player_id=opponent['id'],
|
|
effect_name="Poison",
|
|
effect_icon="🧪",
|
|
effect_type="damage",
|
|
damage_per_tick=effects['poison_damage'],
|
|
ticks_remaining=effects['poison_duration'],
|
|
persist_after_combat=True,
|
|
source=f"skill_poison:{skill.id}"
|
|
)
|
|
messages.append(create_combat_message(
|
|
"skill_effect", origin="player", message=f"🧪 Poisoned! ({effects['poison_damage']} dmg/turn)"
|
|
))
|
|
|
|
# Stun chance
|
|
if 'stun_chance' in effects and random.random() < effects['stun_chance']:
|
|
# Stun in PvP can be modeled as taking away a turn
|
|
await db.add_effect(
|
|
player_id=opponent['id'],
|
|
effect_name="Stunned",
|
|
effect_icon="💫",
|
|
effect_type="debuff",
|
|
ticks_remaining=1,
|
|
persist_after_combat=False,
|
|
source="skill_stun"
|
|
)
|
|
messages.append(create_combat_message(
|
|
"skill_effect", origin="player", message="💫 Stunned! (Currently skip effect)"
|
|
))
|
|
|
|
# Weapon durability
|
|
if weapon_inv_id and inv_item 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:
|
|
messages.append(create_combat_message("weapon_broke", origin="player", item_name=weapon_def.name if weapon_def else "weapon"))
|
|
await db.unequip_item(current_player['id'], 'weapon')
|
|
|
|
# Heal skills
|
|
if 'heal_percent' in effects:
|
|
heal_amount = int(current_player['max_hp'] * effects['heal_percent'])
|
|
new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount)
|
|
actual_heal = new_hp - current_player['hp']
|
|
if actual_heal > 0:
|
|
await db.update_player(current_player['id'], hp=new_hp)
|
|
current_player['hp'] = new_hp
|
|
messages.append(create_combat_message(
|
|
"skill_heal", origin="player", heal=actual_heal, skill_icon=skill.icon
|
|
))
|
|
last_action_text = f"{current_player['name']} used {skill.name} and healed for {actual_heal} HP!"
|
|
|
|
# Fortify
|
|
if 'armor_boost' in effects:
|
|
await db.add_effect(
|
|
player_id=current_player['id'],
|
|
effect_name="Fortify",
|
|
effect_icon="🛡️",
|
|
effect_type="buff",
|
|
value=effects['armor_boost'],
|
|
ticks_remaining=effects['duration'],
|
|
persist_after_combat=False,
|
|
source=f"skill_fortify:{skill.id}"
|
|
)
|
|
messages.append(create_combat_message(
|
|
"skill_effect", origin="player", message=f"🛡️ Fortified! (+{effects['armor_boost']} Armor)"
|
|
))
|
|
last_action_text = f"{current_player['name']} used {skill.name} and bolstered their defenses!"
|
|
|
|
# Process opponent HP if damage done
|
|
if damage_done > 0:
|
|
await db.update_player(opponent['id'], hp=new_opponent_hp)
|
|
if new_opponent_hp <= 0:
|
|
last_action_text += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!"
|
|
messages.append(create_combat_message("victory", origin="neutral", npc_name=opponent['name']))
|
|
combat_over = True
|
|
winner_id = current_player['id']
|
|
|
|
await db.update_player(opponent['id'], hp=0, is_dead=True)
|
|
|
|
# Create corpse
|
|
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')
|
|
})
|
|
|
|
corpse_data = None
|
|
if inventory_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])
|
|
)
|
|
await db.clear_inventory(opponent['id'])
|
|
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 statistics
|
|
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=actual_damage, pvp_attacks_landed=1, increment=True)
|
|
|
|
# Broadcast corpse
|
|
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 corpse_data:
|
|
broadcast_data["corpse"] = 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=actual_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)
|
|
|
|
# End of turn swap
|
|
updates = {
|
|
'turn': 'defender' if is_attacker else 'attacker',
|
|
'turn_started_at': time.time(),
|
|
'last_action': f"{last_action_text}|{time.time()}"
|
|
}
|
|
await db.update_pvp_combat(pvp_combat['id'], updates)
|
|
|
|
else:
|
|
# Skill didn't do damage, but turn still ends
|
|
updates = {
|
|
'turn': 'defender' if is_attacker else 'attacker',
|
|
'turn_started_at': time.time(),
|
|
'last_action': f"{last_action_text}|{time.time()}"
|
|
}
|
|
await db.update_pvp_combat(pvp_combat['id'], updates)
|
|
|
|
elif req.action == 'use_item':
|
|
if not req.item_id:
|
|
raise HTTPException(status_code=400, detail="item_id required for use_item action")
|
|
|
|
player_inventory = await db.get_inventory(current_player['id'])
|
|
inv_item = next((item for item in player_inventory if item['item_id'] == req.item_id), None)
|
|
|
|
if not inv_item:
|
|
raise HTTPException(status_code=400, detail="Item not found in inventory")
|
|
|
|
item_def = ITEMS_MANAGER.get_item(req.item_id)
|
|
if not item_def or not item_def.combat_usable:
|
|
raise HTTPException(status_code=400, detail="This item cannot be used in combat")
|
|
|
|
item_name = get_locale_string(item_def.name, locale)
|
|
effects_applied = []
|
|
|
|
if item_def.effects.get('status_effect'):
|
|
status_data = item_def.effects['status_effect']
|
|
await db.add_effect(
|
|
player_id=current_player['id'],
|
|
effect_name=status_data['name'],
|
|
effect_icon=status_data.get('icon', '✨'),
|
|
effect_type=status_data.get('type', 'buff'),
|
|
damage_per_tick=status_data.get('damage_per_tick', 0),
|
|
value=status_data.get('value', 0),
|
|
ticks_remaining=status_data.get('ticks', 3),
|
|
persist_after_combat=True,
|
|
source=f"item:{item_def.id}"
|
|
)
|
|
effects_applied.append(f"Applied {status_data['name']}")
|
|
|
|
if item_def.effects.get('cures'):
|
|
for cure_effect in item_def.effects['cures']:
|
|
if await db.remove_effect(current_player['id'], cure_effect):
|
|
effects_applied.append(f"Cured {cure_effect}")
|
|
|
|
if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0:
|
|
item_effectiveness = current_player_stats.get('item_effectiveness', 1.0)
|
|
restore_amount = int(item_def.effects['hp_restore'] * item_effectiveness)
|
|
new_hp = min(current_player['max_hp'], current_player['hp'] + restore_amount)
|
|
actual_heal = new_hp - current_player['hp']
|
|
if actual_heal > 0:
|
|
await db.update_player(current_player['id'], hp=new_hp)
|
|
current_player['hp'] = new_hp
|
|
effects_applied.append(get_game_message('healed_amount_text', locale, amount=actual_heal))
|
|
messages.append(create_combat_message("item_heal", origin="player", heal=actual_heal))
|
|
|
|
if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0:
|
|
item_effectiveness = current_player_stats.get('item_effectiveness', 1.0)
|
|
restore_amount = int(item_def.effects['stamina_restore'] * item_effectiveness)
|
|
new_stamina = min(current_player['max_stamina'], current_player['stamina'] + restore_amount)
|
|
actual_restore = new_stamina - current_player['stamina']
|
|
if actual_restore > 0:
|
|
await db.update_player_stamina(current_player['id'], new_stamina)
|
|
effects_applied.append(f"Restored {actual_restore} stamina")
|
|
messages.append(create_combat_message("item_restore", origin="player", stat="stamina", amount=actual_restore))
|
|
|
|
if inv_item['quantity'] > 1:
|
|
await db.update_inventory_quantity(inv_item['id'], inv_item['quantity'] - 1)
|
|
else:
|
|
await db.remove_from_inventory(inv_item['id'])
|
|
|
|
messages.append(create_combat_message(
|
|
"use_item", origin="player", item_name=item_name,
|
|
message=get_game_message('used_item_text', locale, name=item_name) + " (" + ", ".join(effects_applied) + ")"
|
|
))
|
|
last_action_text = f"{current_player['name']} used {item_name}!"
|
|
|
|
# End of turn swap
|
|
updates = {
|
|
'turn': 'defender' if is_attacker else 'attacker',
|
|
'turn_started_at': time.time(),
|
|
'last_action': f"{last_action_text}|{time.time()}"
|
|
}
|
|
await db.update_pvp_combat(pvp_combat['id'], updates)
|
|
|
|
elif req.action == 'flee':
|
|
# 50% chance to flee
|
|
if random.random() < 0.5:
|
|
messages.append(create_combat_message(
|
|
"flee_success",
|
|
origin="player",
|
|
message=get_game_message('flee_success_text', locale, name=player['name'])
|
|
))
|
|
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.remove_non_persistent_effects(player['id'])
|
|
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": 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']
|
|
)
|
|
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)
|
|
|
|
messages.append(create_combat_message(
|
|
"flee_fail",
|
|
origin="enemy",
|
|
npc_name=npc_def.name,
|
|
damage=npc_damage,
|
|
message=get_game_message('flee_fail_text', locale, name=player['name'])
|
|
))
|
|
|
|
if new_player_hp <= 0:
|
|
messages.append(create_combat_message(
|
|
"player_defeated",
|
|
origin="neutral",
|
|
npc_name=npc_def.name
|
|
))
|
|
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')
|
|
})
|
|
|
|
# Only create corpse if player has items
|
|
corpse_data = None
|
|
if inventory_items:
|
|
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()
|
|
}
|
|
else:
|
|
logger.info(f"Player {player['name']} died (failed flee) with no items, skipping corpse creation")
|
|
|
|
# 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.remove_non_persistent_effects(player['id'])
|
|
await db.end_combat(player['id'])
|
|
|
|
# Broadcast to location that player died (and corpse if created)
|
|
logger.info(f"Broadcasting player_died (failed flee) to location {combat['location_id']} for player {player['name']}")
|
|
broadcast_data = {
|
|
"message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']),
|
|
"action": "player_died",
|
|
"player_id": player['id']
|
|
}
|
|
if corpse_data:
|
|
broadcast_data["corpse"] = 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']
|
|
)
|
|
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()})
|
|
|
|
elif req.action == 'skill':
|
|
# ── SKILL ACTION ──
|
|
if not req.skill_id:
|
|
raise HTTPException(status_code=400, detail="skill_id required for skill action")
|
|
|
|
from ..services.skills import skills_manager
|
|
skill = skills_manager.get_skill(req.skill_id)
|
|
if not skill:
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
|
|
# Check unlocked
|
|
stat_val = player.get(skill.stat_requirement, 0)
|
|
if stat_val < skill.stat_threshold or player['level'] < skill.level_requirement:
|
|
raise HTTPException(status_code=400, detail="Skill not unlocked")
|
|
|
|
# Check cooldown
|
|
active_effects = await db.get_player_effects(player['id'])
|
|
cd_source = f"cd:{skill.id}"
|
|
for eff in active_effects:
|
|
if eff.get('source') == cd_source and eff.get('ticks_remaining', 0) > 0:
|
|
raise HTTPException(status_code=400, detail=f"Skill on cooldown ({eff['ticks_remaining']} turns)")
|
|
|
|
# Check stamina
|
|
if player['stamina'] < skill.stamina_cost:
|
|
raise HTTPException(status_code=400, detail="Not enough stamina")
|
|
|
|
# Deduct stamina
|
|
new_stamina = player['stamina'] - skill.stamina_cost
|
|
await db.update_player_stamina(player['id'], new_stamina)
|
|
player['stamina'] = new_stamina
|
|
|
|
# Add cooldown effect
|
|
if skill.cooldown > 0:
|
|
await db.add_effect(
|
|
player_id=player['id'],
|
|
effect_name=f"{skill.id}_cooldown",
|
|
effect_icon="⏳",
|
|
effect_type="cooldown",
|
|
value=0,
|
|
ticks_remaining=skill.cooldown,
|
|
persist_after_combat=False,
|
|
source=cd_source
|
|
)
|
|
|
|
# Get weapon info
|
|
equipment = await db.get_all_equipment(player['id'])
|
|
weapon_damage = 0
|
|
inv_item = None
|
|
weapon_inv_id = None
|
|
weapon_def = None
|
|
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_inv_id = inv_item['id']
|
|
|
|
effects = skill.effects
|
|
new_npc_hp = combat['npc_hp']
|
|
combat_over = False
|
|
player_won = False
|
|
|
|
# ── Damage skills ──
|
|
if 'damage_multiplier' in effects:
|
|
base_damage = 5
|
|
strength_bonus = int(player['strength'] * 1.5)
|
|
level_bonus = player['level']
|
|
variance = random.randint(-2, 2)
|
|
raw_damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
|
|
|
multiplier = effects['damage_multiplier']
|
|
|
|
# Execute check
|
|
if 'execute_threshold' in effects:
|
|
npc_hp_pct = combat['npc_hp'] / combat['npc_max_hp'] if combat['npc_max_hp'] > 0 else 1
|
|
if npc_hp_pct <= effects['execute_threshold']:
|
|
multiplier = effects.get('execute_multiplier', multiplier)
|
|
|
|
# Exploit Weakness check
|
|
if effects.get('requires_analyzed'):
|
|
# Check if NPC has been analyzed this combat
|
|
analyzed = combat.get('npc_status_effects', '') or ''
|
|
if 'analyzed' not in analyzed:
|
|
multiplier = 1.0 # No bonus if not analyzed
|
|
|
|
damage = max(1, int(raw_damage * multiplier))
|
|
|
|
# Guaranteed crit
|
|
if effects.get('guaranteed_crit'):
|
|
damage = int(damage * 1.5)
|
|
|
|
# Multi-hit
|
|
num_hits = effects.get('hits', 1)
|
|
total_damage = 0
|
|
|
|
for hit in range(num_hits):
|
|
hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier))
|
|
|
|
# Armor penetration
|
|
npc_defense = getattr(npc_def, 'defense', 0)
|
|
if 'armor_penetration' in effects:
|
|
npc_defense = int(npc_defense * (1 - effects['armor_penetration']))
|
|
|
|
actual_hit = max(1, hit_dmg - npc_defense)
|
|
total_damage += actual_hit
|
|
new_npc_hp = max(0, new_npc_hp - actual_hit)
|
|
|
|
messages.append(create_combat_message(
|
|
"skill_attack",
|
|
origin="player",
|
|
damage=total_damage,
|
|
skill_name=skill.name,
|
|
skill_icon=skill.icon,
|
|
hits=num_hits
|
|
))
|
|
|
|
# Lifesteal
|
|
if 'lifesteal' in effects:
|
|
heal_amount = int(total_damage * effects['lifesteal'])
|
|
new_hp = min(player['max_hp'], player['hp'] + heal_amount)
|
|
if new_hp > player['hp']:
|
|
await db.update_player_hp(player['id'], new_hp)
|
|
player['hp'] = new_hp
|
|
messages.append(create_combat_message(
|
|
"skill_heal", origin="player", heal=heal_amount, skill_icon="🩸"
|
|
))
|
|
|
|
# Poison DoT
|
|
if 'poison_damage' in effects:
|
|
poison_str = f"poison:{effects['poison_damage']}:{effects['poison_duration']}"
|
|
existing = combat.get('npc_status_effects', '') or ''
|
|
if existing:
|
|
existing += '|' + poison_str
|
|
else:
|
|
existing = poison_str
|
|
await db.update_combat(player['id'], {'npc_status_effects': existing})
|
|
messages.append(create_combat_message(
|
|
"skill_effect", origin="player", message=f"🧪 Poisoned! ({effects['poison_damage']} dmg/turn)"
|
|
))
|
|
|
|
# Stun chance
|
|
if 'stun_chance' in effects and random.random() < effects['stun_chance']:
|
|
messages.append(create_combat_message(
|
|
"skill_effect", origin="player", message="💫 Stunned!"
|
|
))
|
|
|
|
# Weapon durability
|
|
if weapon_inv_id and inv_item 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:
|
|
messages.append(create_combat_message("weapon_broke", origin="player", item_name=weapon_def.name if weapon_def else "weapon"))
|
|
await db.unequip_item(player['id'], 'weapon')
|
|
|
|
# ── Heal skills ──
|
|
if 'heal_percent' in effects:
|
|
heal_amount = int(player['max_hp'] * effects['heal_percent'])
|
|
new_hp = min(player['max_hp'], player['hp'] + heal_amount)
|
|
actual = new_hp - player['hp']
|
|
if actual > 0:
|
|
await db.update_player_hp(player['id'], new_hp)
|
|
player['hp'] = new_hp
|
|
messages.append(create_combat_message(
|
|
"skill_heal", origin="player", heal=actual, skill_name=skill.name, skill_icon=skill.icon
|
|
))
|
|
|
|
# ── Stamina restore skills ──
|
|
if 'stamina_restore_percent' in effects:
|
|
restore = int(player['max_stamina'] * effects['stamina_restore_percent'])
|
|
new_stam = min(player['max_stamina'], player['stamina'] + restore)
|
|
actual = new_stam - player['stamina']
|
|
if actual > 0:
|
|
await db.update_player_stamina(player['id'], new_stam)
|
|
player['stamina'] = new_stam
|
|
messages.append(create_combat_message(
|
|
"skill_effect", origin="player", message=f"⚡ +{actual} Stamina"
|
|
))
|
|
|
|
# ── Buff skills ──
|
|
if 'buff' in effects:
|
|
buff_name_raw = effects['buff']
|
|
duration = effects.get('buff_duration', 2)
|
|
value = 0
|
|
if 'damage_reduction' in effects:
|
|
value = int(effects['damage_reduction'] * 100)
|
|
elif 'damage_bonus' in effects:
|
|
value = int(effects['damage_bonus'] * 100)
|
|
|
|
await db.add_effect(
|
|
player_id=player['id'],
|
|
effect_name=buff_name_raw,
|
|
effect_icon=skill.icon,
|
|
effect_type='buff',
|
|
value=value,
|
|
ticks_remaining=duration,
|
|
persist_after_combat=False,
|
|
source=f'skill:{skill.id}'
|
|
)
|
|
messages.append(create_combat_message(
|
|
"skill_buff", origin="player",
|
|
skill_name=skill.name, skill_icon=skill.icon, duration=duration
|
|
))
|
|
|
|
# ── Analyze skill ──
|
|
if effects.get('mark_analyzed'):
|
|
existing = combat.get('npc_status_effects', '') or ''
|
|
if 'analyzed' not in existing:
|
|
if existing:
|
|
existing += '|analyzed:0:99'
|
|
else:
|
|
existing = 'analyzed:0:99'
|
|
await db.update_combat(player['id'], {'npc_status_effects': existing})
|
|
|
|
npc_hp_pct = int((combat['npc_hp'] / combat['npc_max_hp']) * 100) if combat['npc_max_hp'] > 0 else 0
|
|
intent = combat.get('npc_intent', 'attack')
|
|
messages.append(create_combat_message(
|
|
"skill_analyze", origin="player",
|
|
skill_icon=skill.icon,
|
|
npc_name=npc_def.name,
|
|
npc_hp_pct=npc_hp_pct,
|
|
npc_intent=intent
|
|
))
|
|
|
|
# Check NPC death
|
|
if new_npc_hp <= 0:
|
|
messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name))
|
|
combat_over = True
|
|
player_won = True
|
|
|
|
# Award XP
|
|
xp_reward = npc_def.xp_reward
|
|
current_xp = player['xp'] + xp_reward
|
|
await db.update_player(player['id'], xp=current_xp)
|
|
player['xp'] = current_xp
|
|
|
|
messages.append(create_combat_message("xp_gained", origin="neutral", xp=xp_reward))
|
|
|
|
await db.update_player_statistics(player['id'], enemies_killed=1, increment=True)
|
|
|
|
# Level up check
|
|
level_result = await game_logic.check_and_apply_level_up(player['id'])
|
|
if level_result['leveled_up']:
|
|
messages.append(create_combat_message("level_up", origin="neutral", new_level=level_result['new_level']))
|
|
|
|
# Loot
|
|
loot_items = npc_def.loot if hasattr(npc_def, 'loot') else []
|
|
generated_loot = []
|
|
if loot_items:
|
|
for loot in loot_items:
|
|
if random.random() < loot.get('chance', 1.0):
|
|
qty = random.randint(loot.get('min', 1), loot.get('max', 1))
|
|
# Only append message in combat log, actual items are in corpse
|
|
messages.append(create_combat_message("loot", origin="neutral", item_id=loot['item_id'], quantity=qty))
|
|
generated_loot.append({"item_id": loot['item_id'], "quantity": qty})
|
|
|
|
# Create corpse
|
|
import json
|
|
# Convert CorpseLoot objects to dicts
|
|
corpse_loot = npc_def.corpse_loot if hasattr(npc_def, 'corpse_loot') else []
|
|
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(
|
|
combat['npc_id'],
|
|
combat.get('location_id', player.get('location_id', '')),
|
|
json.dumps(corpse_loot_dicts)
|
|
)
|
|
|
|
# Update quests
|
|
try:
|
|
if QUESTS_DATA:
|
|
active_quests = await db.get_character_quests(player['id'])
|
|
for q_record in active_quests:
|
|
if q_record['status'] != 'active':
|
|
continue
|
|
|
|
q_def = QUESTS_DATA.get(q_record['quest_id'])
|
|
if not q_def: continue
|
|
|
|
objectives = q_def.get('objectives', [])
|
|
current_progress = q_record.get('progress') or {}
|
|
new_progress = current_progress.copy()
|
|
progres_changed = False
|
|
|
|
for obj in objectives:
|
|
if obj['type'] == 'kill_count' and obj['target'] == combat['npc_id']:
|
|
current_count = current_progress.get(obj['target'], 0)
|
|
if current_count < obj['count']:
|
|
new_progress[obj['target']] = current_count + 1
|
|
progres_changed = True
|
|
|
|
if progres_changed:
|
|
progress_str = ""
|
|
for obj in objectives:
|
|
target = obj['target']
|
|
req_count = obj['count']
|
|
curr = new_progress.get(target, 0)
|
|
if obj['type'] == 'kill_count':
|
|
if target == combat['npc_id']:
|
|
progress_str = f" ({curr}/{req_count})"
|
|
|
|
await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, 'active')
|
|
messages.append(create_combat_message(
|
|
"quest_update",
|
|
origin="system",
|
|
message=f"{get_locale_string(q_def['title'], locale)}{progress_str}"
|
|
))
|
|
except Exception as e:
|
|
logger.error(f"Failed to update quest progress in skill execution: {e}")
|
|
|
|
await db.remove_non_persistent_effects(player['id'])
|
|
await db.end_combat(player['id'])
|
|
|
|
# Update Redis cache
|
|
if redis_manager:
|
|
await redis_manager.delete_combat_state(player['id'])
|
|
await redis_manager.update_player_session_field(player['id'], 'xp', current_xp)
|
|
else:
|
|
# NPC turn for skill usage
|
|
from ..services.stats import calculate_derived_stats
|
|
stats = await calculate_derived_stats(player['id'], redis_manager)
|
|
npc_attack_messages, player_defeated = await game_logic.npc_attack(
|
|
player['id'],
|
|
{'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp'],
|
|
'npc_intent': combat.get('npc_intent', 'attack'),
|
|
'npc_status_effects': combat.get('npc_status_effects', '')},
|
|
npc_def,
|
|
reduce_armor_durability,
|
|
player_stats=stats
|
|
)
|
|
messages.extend(npc_attack_messages)
|
|
if player_defeated:
|
|
await db.remove_non_persistent_effects(player['id'])
|
|
combat_over = True
|
|
elif req.action == 'use_item':
|
|
combat_over = False
|
|
# Validate item_id provided
|
|
if not req.item_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="item_id required for use_item action"
|
|
)
|
|
|
|
# Get the item from inventory
|
|
player_inventory = await db.get_inventory(player['id'])
|
|
inv_item = None
|
|
for item in player_inventory:
|
|
if item['item_id'] == req.item_id:
|
|
inv_item = item
|
|
break
|
|
|
|
if not inv_item:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Item not found in inventory"
|
|
)
|
|
|
|
# Get item definition
|
|
item_def = ITEMS_MANAGER.get_item(req.item_id)
|
|
if not item_def:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Unknown item"
|
|
)
|
|
|
|
# Check if item is combat usable
|
|
if not item_def.combat_usable:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="This item cannot be used in combat"
|
|
)
|
|
|
|
# Apply item effects
|
|
item_name = get_locale_string(item_def.name, locale)
|
|
effects_applied = []
|
|
|
|
# 1. Apply Status Effects (e.g. Regeneration from Bandage)
|
|
if item_def.effects.get('status_effect'):
|
|
status_data = item_def.effects['status_effect']
|
|
status_name = status_data['name']
|
|
await db.add_effect(
|
|
player_id=player['id'],
|
|
effect_name=status_name,
|
|
effect_icon=status_data.get('icon', '✨'),
|
|
effect_type=status_data.get('type', 'buff'),
|
|
damage_per_tick=status_data.get('damage_per_tick', 0),
|
|
value=status_data.get('value', 0),
|
|
ticks_remaining=status_data.get('ticks', 3),
|
|
persist_after_combat=True, # Consumable effects usually persist
|
|
source=f"item:{item_def.id}"
|
|
)
|
|
effects_applied.append(f"Applied {status_data['name']}")
|
|
|
|
# 2. Cure Status Effects
|
|
if item_def.effects.get('cures'):
|
|
cures = item_def.effects['cures']
|
|
for cure_effect in cures:
|
|
if await db.remove_effect(player['id'], cure_effect):
|
|
effects_applied.append(f"Cured {cure_effect}")
|
|
|
|
# 3. Handle Direct healing (legacy/instant)
|
|
if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0:
|
|
item_effectiveness = stats.get('item_effectiveness', 1.0)
|
|
base_hp_restore = item_def.effects['hp_restore']
|
|
hp_restore = int(base_hp_restore * item_effectiveness)
|
|
|
|
old_hp = player['hp']
|
|
new_hp = min(player.get('max_hp', 100), old_hp + hp_restore)
|
|
actual_restored = new_hp - old_hp
|
|
if actual_restored > 0:
|
|
await db.update_player_hp(player['id'], new_hp)
|
|
effects_applied.append(f"+{actual_restored} HP")
|
|
|
|
if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0:
|
|
item_effectiveness = stats.get('item_effectiveness', 1.0)
|
|
base_stamina_restore = item_def.effects['stamina_restore']
|
|
stamina_restore = int(base_stamina_restore * item_effectiveness)
|
|
|
|
old_stamina = player['stamina']
|
|
new_stamina = min(player.get('max_stamina', 100), old_stamina + stamina_restore)
|
|
actual_restored = new_stamina - old_stamina
|
|
if actual_restored > 0:
|
|
await db.update_player_stamina(player['id'], new_stamina)
|
|
effects_applied.append(f"+{actual_restored} Stamina")
|
|
|
|
# Handle combat effects (throwables)
|
|
combat_effects = item_def.combat_effects or {}
|
|
|
|
# Direct damage from throwable
|
|
if combat_effects.get('damage_min') and combat_effects.get('damage_max'):
|
|
damage = random.randint(combat_effects['damage_min'], combat_effects['damage_max'])
|
|
new_npc_hp = max(0, combat['npc_hp'] - damage)
|
|
effects_applied.append(f"{damage} damage")
|
|
|
|
messages.append(create_combat_message(
|
|
"item_damage",
|
|
origin="player",
|
|
damage=damage,
|
|
item_name=item_name
|
|
))
|
|
|
|
# Check if NPC is defeated
|
|
if new_npc_hp <= 0:
|
|
messages.append(create_combat_message(
|
|
"victory",
|
|
origin="neutral",
|
|
npc_name=npc_def.name
|
|
))
|
|
combat_over = True
|
|
player_won = True
|
|
|
|
# Award XP
|
|
xp_gained = npc_def.xp_reward
|
|
new_xp = player['xp'] + xp_gained
|
|
messages.append(create_combat_message(
|
|
"xp_gain",
|
|
origin="player",
|
|
amount=xp_gained
|
|
))
|
|
await db.update_player(player['id'], xp=new_xp)
|
|
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']:
|
|
messages.append(create_combat_message(
|
|
"level_up",
|
|
origin="player",
|
|
level=level_up_result['new_level'],
|
|
stat_points=level_up_result['levels_gained']
|
|
))
|
|
|
|
# Create corpse with loot
|
|
import json as json_module
|
|
corpse_loot = npc_def.corpse_loot if hasattr(npc_def, 'corpse_loot') else []
|
|
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_module.dumps(corpse_loot_dicts)
|
|
)
|
|
await db.remove_non_persistent_effects(player['id'])
|
|
await db.end_combat(player['id'])
|
|
else:
|
|
# Update NPC HP
|
|
await db.update_combat(player['id'], {'npc_hp': new_npc_hp})
|
|
|
|
# Apply status effect from item (e.g., burning from molotov)
|
|
status_effect = combat_effects.get('status')
|
|
if status_effect and not combat_over:
|
|
# Apply to NPC via combat status (simplified - NPC status stored in combat record)
|
|
npc_status = f"{status_effect['name']}:{status_effect.get('damage_per_tick', 0)}:{status_effect.get('ticks', 1)}"
|
|
await db.update_combat(player['id'], {'npc_status_effects': npc_status})
|
|
|
|
messages.append(create_combat_message(
|
|
"effect_applied",
|
|
origin="player",
|
|
effect_name=status_effect['name'],
|
|
effect_icon=status_effect.get('icon', '🔥'),
|
|
target="enemy"
|
|
))
|
|
|
|
# Consume the item
|
|
await db.remove_item_from_inventory(player['id'], req.item_id, 1)
|
|
await db.update_player_statistics(player['id'], items_used=1, increment=True)
|
|
|
|
# Add item used message
|
|
effects_str = f" ({', '.join(effects_applied)})" if effects_applied else ""
|
|
|
|
# Calculate total restored amounts for frontend floating text
|
|
hp_restored_val = 0
|
|
stamina_restored_val = 0
|
|
|
|
if item_def.effects.get('hp_restore'):
|
|
hp_restored_val = min(player.get('max_hp', 100), old_hp + item_def.effects['hp_restore']) - old_hp
|
|
|
|
if item_def.effects.get('stamina_restore'):
|
|
stamina_restored_val = min(player.get('max_stamina', 100), old_stamina + item_def.effects['stamina_restore']) - old_stamina
|
|
|
|
messages.append(create_combat_message(
|
|
"item_used",
|
|
origin="player",
|
|
item_name=item_name,
|
|
effects=effects_str,
|
|
hp_restore=hp_restored_val if hp_restored_val > 0 else None,
|
|
stamina_restore=stamina_restored_val if stamina_restored_val > 0 else None
|
|
))
|
|
|
|
# NPC's turn after using item (if combat not over)
|
|
if not combat_over:
|
|
npc_attack_messages, player_defeated = await game_logic.npc_attack(
|
|
player['id'],
|
|
{'npc_hp': combat['npc_hp'], 'npc_max_hp': combat['npc_max_hp']},
|
|
npc_def,
|
|
reduce_armor_durability
|
|
)
|
|
messages.extend(npc_attack_messages)
|
|
|
|
if player_defeated:
|
|
await db.remove_non_persistent_effects(player['id'])
|
|
combat_over = True
|
|
|
|
|
|
# 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,
|
|
"messages": messages,
|
|
"combat_over": combat_over,
|
|
"player_won": player_won if combat_over else None,
|
|
"combat": updated_combat if updated_combat else None,
|
|
"player": {
|
|
"hp": updated_player['hp'],
|
|
"max_hp": updated_player.get('max_hp', updated_player.get('max_health')),
|
|
"xp": updated_player['xp'],
|
|
"level": updated_player['level']
|
|
},
|
|
"quest_updates": quest_updates if 'quest_updates' in locals() else []
|
|
}
|
|
|
|
|
|
@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"""
|
|
# 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")
|
|
|
|
# Extract locale
|
|
locale = request.headers.get('Accept-Language', 'en')
|
|
|
|
# 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)")
|
|
|
|
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=req.target_player_id,
|
|
location_id=attacker['location_id'],
|
|
turn_timeout=PVP_TURN_TIMEOUT
|
|
)
|
|
|
|
# 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": 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 that PvP combat started - both players should be removed from view
|
|
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']] # Remove both from location view
|
|
},
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
},
|
|
exclude_player_id=None # Send to everyone including combatants
|
|
)
|
|
|
|
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}
|
|
|
|
# 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'],
|
|
"image": "/images/characters/default.webp"
|
|
},
|
|
"defender": {
|
|
"id": defender['id'],
|
|
"username": defender['name'],
|
|
"level": defender['level'],
|
|
"hp": defender['hp'], # Use actual player 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)
|
|
}
|
|
}
|
|
|
|
|
|
class PvPAcknowledgeRequest(BaseModel):
|
|
combat_id: int
|
|
|
|
|
|
@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'])
|
|
|
|
# Extract locale from Accept-Language header
|
|
locale = request.headers.get('Accept-Language', 'en')
|
|
|
|
# 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": get_game_message('player_returned_pvp', locale, player_name=player['name'])
|
|
},
|
|
"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,
|
|
request: Request,
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Perform a PvP combat action"""
|
|
import random
|
|
import time
|
|
|
|
# Extract locale
|
|
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 for both players
|
|
from ..services.stats import calculate_derived_stats
|
|
current_player_stats = await calculate_derived_stats(current_player['id'], redis_manager)
|
|
# Opponent stats won't be used for attack calculation but could be used for defense logic
|
|
# opponent_stats = await calculate_derived_stats(opponent['id'], redis_manager)
|
|
|
|
messages = []
|
|
combat_over = False
|
|
winner_id = None
|
|
|
|
|
|
# Track the last action string for DB history
|
|
last_action_text = ""
|
|
|
|
# Process status effects (bleeding, poison, etc.) before action
|
|
active_effects = await db.tick_player_effects(current_player['id'])
|
|
|
|
if active_effects:
|
|
from ..game_logic import calculate_status_impact
|
|
total_impact = calculate_status_impact(active_effects)
|
|
|
|
if total_impact > 0:
|
|
damage = total_impact
|
|
new_hp = max(0, current_player['hp'] - damage)
|
|
await db.update_player(current_player['id'], hp=new_hp)
|
|
current_player['hp'] = new_hp
|
|
|
|
messages.append(create_combat_message(
|
|
"effect_damage",
|
|
origin="player",
|
|
damage=damage,
|
|
effect_name="status effects"
|
|
))
|
|
|
|
if new_hp <= 0:
|
|
messages.append(create_combat_message("died", origin="player", message="You died from status effects!"))
|
|
combat_over = True
|
|
winner_id = opponent['id']
|
|
|
|
# Update current player to dead state
|
|
await db.update_player(current_player['id'], hp=0, is_dead=True)
|
|
|
|
# Create corpse
|
|
import json
|
|
import time as time_module
|
|
inventory = await db.get_inventory(current_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')
|
|
})
|
|
|
|
corpse_data = None
|
|
if inventory_items:
|
|
corpse_id = await db.create_player_corpse(
|
|
player_name=current_player['name'],
|
|
location_id=current_player['location_id'],
|
|
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
|
|
)
|
|
await db.clear_inventory(current_player['id'])
|
|
corpse_data = {
|
|
"id": f"player_{corpse_id}",
|
|
"type": "player",
|
|
"name": f"{current_player['name']}'s Corpse",
|
|
"emoji": "⚰️",
|
|
"player_name": current_player['name'],
|
|
"loot_count": len(inventory_items),
|
|
"items": inventory_items,
|
|
"timestamp": time_module.time()
|
|
}
|
|
|
|
# Update PvP statistics for both players
|
|
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 corpse
|
|
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'])
|
|
|
|
elif total_impact < 0:
|
|
heal = abs(total_impact)
|
|
new_hp = min(current_player_stats.get('max_hp', current_player['max_hp']), current_player['hp'] + heal)
|
|
actual_heal = new_hp - current_player['hp']
|
|
|
|
if actual_heal > 0:
|
|
await db.update_player(current_player['id'], hp=new_hp)
|
|
current_player['hp'] = new_hp
|
|
messages.append(create_combat_message(
|
|
"effect_heal",
|
|
origin="player",
|
|
heal=actual_heal,
|
|
effect_name="status effects"
|
|
))
|
|
|
|
# Stop processing action if player died from status effects
|
|
if combat_over:
|
|
pass
|
|
elif req.action == 'attack':
|
|
# Calculate damage (unified formula with derived stats)
|
|
base_damage = current_player_stats.get('attack_power', 5)
|
|
|
|
# Check for equipped weapon to apply durability loss
|
|
# (Attack power from the weapon is already included in stats['attack_power'])
|
|
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:
|
|
# 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:
|
|
messages.append(create_combat_message(
|
|
"weapon_broke",
|
|
origin="player",
|
|
item_name=weapon_def.name
|
|
))
|
|
await db.unequip_item(current_player['id'], 'weapon')
|
|
|
|
variance = random.randint(-2, 2)
|
|
damage = max(1, base_damage + variance)
|
|
|
|
# Check for critical hit
|
|
is_critical = False
|
|
crit_chance = current_player_stats.get('crit_chance', 0.05)
|
|
if random.random() < crit_chance:
|
|
is_critical = True
|
|
damage = int(damage * current_player_stats.get('crit_damage', 1.5))
|
|
|
|
# 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)
|
|
|
|
if is_critical:
|
|
messages.append(create_combat_message(
|
|
"combat_crit",
|
|
origin="player"
|
|
))
|
|
last_action_text += f"\nCritical Hit! "
|
|
|
|
# Structure the attack message
|
|
messages.append(create_combat_message(
|
|
"player_attack",
|
|
origin="player",
|
|
damage=actual_damage,
|
|
armor_absorbed=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)
|
|
|
|
# Construct summary string for DB history/passive player
|
|
last_action_text = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!"
|
|
if armor_absorbed > 0:
|
|
last_action_text += f" (Armor absorbed {armor_absorbed})"
|
|
|
|
for broken in broken_armor:
|
|
messages.append(create_combat_message(
|
|
"item_broken",
|
|
origin="enemy", # Belongs to opponent
|
|
item_name=broken['name'],
|
|
emoji=broken['emoji']
|
|
))
|
|
last_action_text += f"\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!"
|
|
|
|
# Check if opponent defeated
|
|
if new_opponent_hp <= 0:
|
|
last_action_text += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!"
|
|
messages.append(create_combat_message(
|
|
"victory",
|
|
origin="neutral",
|
|
npc_name=opponent['name']
|
|
))
|
|
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')
|
|
})
|
|
|
|
# Only create corpse if opponent has items
|
|
corpse_data = None
|
|
if inventory_items:
|
|
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()
|
|
}
|
|
else:
|
|
logger.info(f"Player {opponent['name']} died (PvP death) with no items, skipping corpse creation")
|
|
|
|
# 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 if created)
|
|
logger.info(f"Broadcasting player_died (PvP death) to location {opponent['location_id']} for player {opponent['name']}")
|
|
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 corpse_data:
|
|
broadcast_data["corpse"] = corpse_data
|
|
|
|
await manager.send_to_location(
|
|
location_id=opponent['location_id'],
|
|
message={
|
|
"type": "location_update",
|
|
"data": broadcast_data,
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
}
|
|
)
|
|
|
|
# End PvP combat
|
|
await db.end_pvp_combat(pvp_combat['id'])
|
|
else:
|
|
# Combat continues
|
|
|
|
# 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"{last_action_text}|{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 == 'skill':
|
|
if not req.item_id:
|
|
raise HTTPException(status_code=400, detail="skill_id (passed as item_id) required")
|
|
|
|
from ..services.skills import skills_manager
|
|
skill_id = req.item_id
|
|
skill = skills_manager.get_skill(skill_id)
|
|
if not skill:
|
|
raise HTTPException(status_code=404, detail="Skill not found")
|
|
|
|
# Check unlocked
|
|
stat_val = current_player.get(skill.stat_requirement, 0)
|
|
if stat_val < skill.stat_threshold or current_player['level'] < skill.level_requirement:
|
|
raise HTTPException(status_code=400, detail="Skill not unlocked")
|
|
|
|
# Check cooldown
|
|
active_effects = await db.get_player_effects(current_player['id'])
|
|
cd_source = f"cd:{skill.id}"
|
|
for eff in active_effects:
|
|
if eff.get('source') == cd_source and eff.get('ticks_remaining', 0) > 0:
|
|
raise HTTPException(status_code=400, detail=f"Skill on cooldown ({eff['ticks_remaining']} turns)")
|
|
|
|
# Check stamina
|
|
if current_player['stamina'] < skill.stamina_cost:
|
|
raise HTTPException(status_code=400, detail="Not enough stamina")
|
|
|
|
# Deduct stamina
|
|
new_stamina = current_player['stamina'] - skill.stamina_cost
|
|
await db.update_player_stamina(current_player['id'], new_stamina)
|
|
current_player['stamina'] = new_stamina
|
|
|
|
# Add cooldown effect
|
|
if skill.cooldown > 0:
|
|
await db.add_effect(
|
|
player_id=current_player['id'],
|
|
effect_name=f"{skill.id}_cooldown",
|
|
effect_icon="⏳",
|
|
effect_type="cooldown",
|
|
value=0,
|
|
ticks_remaining=skill.cooldown,
|
|
persist_after_combat=False,
|
|
source=cd_source
|
|
)
|
|
|
|
# Get weapon info
|
|
equipment = await db.get_all_equipment(current_player['id'])
|
|
weapon_damage = 0
|
|
weapon_inv_id = None
|
|
inv_item = None
|
|
weapon_def = None
|
|
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_inv_id = inv_item['id']
|
|
|
|
effects = skill.effects
|
|
new_opponent_hp = opponent['hp']
|
|
damage_done = 0
|
|
actual_damage = 0
|
|
armor_absorbed = 0
|
|
|
|
# Damage skills
|
|
if 'damage_multiplier' in effects:
|
|
base_damage = 5
|
|
strength_bonus = int(current_player['strength'] * 1.5)
|
|
level_bonus = current_player['level']
|
|
variance = random.randint(-2, 2)
|
|
raw_damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
|
|
|
multiplier = effects['damage_multiplier']
|
|
|
|
if 'execute_threshold' in effects:
|
|
opponent_hp_pct = opponent['hp'] / opponent['max_hp'] if opponent['max_hp'] > 0 else 1
|
|
if opponent_hp_pct <= effects['execute_threshold']:
|
|
multiplier = effects.get('execute_multiplier', multiplier)
|
|
|
|
damage = max(1, int(raw_damage * multiplier))
|
|
if effects.get('guaranteed_crit'):
|
|
damage = int(damage * 1.5)
|
|
|
|
num_hits = effects.get('hits', 1)
|
|
|
|
for hit in range(num_hits):
|
|
hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier))
|
|
absorbed, broken_armor = await reduce_armor_durability(opponent['id'], hit_dmg)
|
|
armor_absorbed += absorbed
|
|
|
|
for broken in broken_armor:
|
|
messages.append(create_combat_message(
|
|
"item_broken",
|
|
origin="enemy",
|
|
item_name=broken['name'],
|
|
emoji=broken['emoji']
|
|
))
|
|
last_action_text += f"\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!"
|
|
|
|
actual_hit = max(1, hit_dmg - absorbed)
|
|
damage_done += actual_hit
|
|
new_opponent_hp = max(0, new_opponent_hp - actual_hit)
|
|
|
|
actual_damage = damage_done
|
|
|
|
messages.append(create_combat_message(
|
|
"skill_attack",
|
|
origin="player",
|
|
damage=damage_done,
|
|
skill_name=skill.name,
|
|
skill_icon=skill.icon,
|
|
hits=num_hits
|
|
))
|
|
last_action_text = f"{current_player['name']} used {skill.name} on {opponent['name']} for {damage_done} damage! (Armor absorbed {armor_absorbed})"
|
|
|
|
# Lifesteal
|
|
if 'lifesteal' in effects:
|
|
heal_amount = int(damage_done * effects['lifesteal'])
|
|
new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount)
|
|
if new_hp > current_player['hp']:
|
|
await db.update_player(current_player['id'], hp=new_hp)
|
|
current_player['hp'] = new_hp
|
|
messages.append(create_combat_message(
|
|
"skill_heal", origin="player", heal=heal_amount, skill_icon="🩸"
|
|
))
|
|
|
|
# Poison DoT
|
|
if 'poison_damage' in effects:
|
|
await db.add_effect(
|
|
player_id=opponent['id'],
|
|
effect_name="Poison",
|
|
effect_icon="🧪",
|
|
effect_type="damage",
|
|
damage_per_tick=effects['poison_damage'],
|
|
ticks_remaining=effects['poison_duration'],
|
|
persist_after_combat=True,
|
|
source=f"skill_poison:{skill.id}"
|
|
)
|
|
messages.append(create_combat_message(
|
|
"skill_effect", origin="player", message=f"🧪 Poisoned! ({effects['poison_damage']} dmg/turn)"
|
|
))
|
|
|
|
# Stun chance
|
|
if 'stun_chance' in effects and random.random() < effects['stun_chance']:
|
|
# Stun in PvP can be modeled as taking away a turn
|
|
await db.add_effect(
|
|
player_id=opponent['id'],
|
|
effect_name="Stunned",
|
|
effect_icon="💫",
|
|
effect_type="debuff",
|
|
ticks_remaining=1,
|
|
persist_after_combat=False,
|
|
source="skill_stun"
|
|
)
|
|
messages.append(create_combat_message(
|
|
"skill_effect", origin="player", message="💫 Stunned! (Currently skip effect)"
|
|
))
|
|
|
|
# Weapon durability
|
|
if weapon_inv_id and inv_item 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:
|
|
messages.append(create_combat_message("weapon_broke", origin="player", item_name=weapon_def.name if weapon_def else "weapon"))
|
|
await db.unequip_item(current_player['id'], 'weapon')
|
|
|
|
# Heal skills
|
|
if 'heal_percent' in effects:
|
|
heal_amount = int(current_player['max_hp'] * effects['heal_percent'])
|
|
new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount)
|
|
actual_heal = new_hp - current_player['hp']
|
|
if actual_heal > 0:
|
|
await db.update_player(current_player['id'], hp=new_hp)
|
|
current_player['hp'] = new_hp
|
|
messages.append(create_combat_message(
|
|
"skill_heal", origin="player", heal=actual_heal, skill_icon=skill.icon
|
|
))
|
|
last_action_text = f"{current_player['name']} used {skill.name} and healed for {actual_heal} HP!"
|
|
|
|
# Fortify
|
|
if 'armor_boost' in effects:
|
|
await db.add_effect(
|
|
player_id=current_player['id'],
|
|
effect_name="Fortify",
|
|
effect_icon="🛡️",
|
|
effect_type="buff",
|
|
value=effects['armor_boost'],
|
|
ticks_remaining=effects['duration'],
|
|
persist_after_combat=False,
|
|
source=f"skill_fortify:{skill.id}"
|
|
)
|
|
messages.append(create_combat_message(
|
|
"skill_effect", origin="player", message=f"🛡️ Fortified! (+{effects['armor_boost']} Armor)"
|
|
))
|
|
last_action_text = f"{current_player['name']} used {skill.name} and bolstered their defenses!"
|
|
|
|
# Process opponent HP if damage done
|
|
if damage_done > 0:
|
|
await db.update_player(opponent['id'], hp=new_opponent_hp)
|
|
if new_opponent_hp <= 0:
|
|
last_action_text += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!"
|
|
messages.append(create_combat_message("victory", origin="neutral", npc_name=opponent['name']))
|
|
combat_over = True
|
|
winner_id = current_player['id']
|
|
|
|
await db.update_player(opponent['id'], hp=0, is_dead=True)
|
|
|
|
# Create corpse
|
|
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')
|
|
})
|
|
|
|
corpse_data = None
|
|
if inventory_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])
|
|
)
|
|
await db.clear_inventory(opponent['id'])
|
|
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 statistics
|
|
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=actual_damage, pvp_attacks_landed=1, increment=True)
|
|
|
|
# Broadcast corpse
|
|
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 corpse_data:
|
|
broadcast_data["corpse"] = 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=actual_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)
|
|
|
|
# End of turn swap
|
|
updates = {
|
|
'turn': 'defender' if is_attacker else 'attacker',
|
|
'turn_started_at': time.time(),
|
|
'last_action': f"{last_action_text}|{time.time()}"
|
|
}
|
|
await db.update_pvp_combat(pvp_combat['id'], updates)
|
|
|
|
else:
|
|
# Skill didn't do damage, but turn still ends
|
|
updates = {
|
|
'turn': 'defender' if is_attacker else 'attacker',
|
|
'turn_started_at': time.time(),
|
|
'last_action': f"{last_action_text}|{time.time()}"
|
|
}
|
|
await db.update_pvp_combat(pvp_combat['id'], updates)
|
|
|
|
elif req.action == 'use_item':
|
|
if not req.item_id:
|
|
raise HTTPException(status_code=400, detail="item_id required for use_item action")
|
|
|
|
player_inventory = await db.get_inventory(current_player['id'])
|
|
inv_item = next((item for item in player_inventory if item['item_id'] == req.item_id), None)
|
|
|
|
if not inv_item:
|
|
raise HTTPException(status_code=400, detail="Item not found in inventory")
|
|
|
|
item_def = ITEMS_MANAGER.get_item(req.item_id)
|
|
if not item_def or not item_def.combat_usable:
|
|
raise HTTPException(status_code=400, detail="This item cannot be used in combat")
|
|
|
|
item_name = get_locale_string(item_def.name, locale)
|
|
effects_applied = []
|
|
|
|
if item_def.effects.get('status_effect'):
|
|
status_data = item_def.effects['status_effect']
|
|
await db.add_effect(
|
|
player_id=current_player['id'],
|
|
effect_name=status_data['name'],
|
|
effect_icon=status_data.get('icon', '✨'),
|
|
effect_type=status_data.get('type', 'buff'),
|
|
damage_per_tick=status_data.get('damage_per_tick', 0),
|
|
value=status_data.get('value', 0),
|
|
ticks_remaining=status_data.get('ticks', 3),
|
|
persist_after_combat=True,
|
|
source=f"item:{item_def.id}"
|
|
)
|
|
effects_applied.append(f"Applied {status_data['name']}")
|
|
|
|
if item_def.effects.get('cures'):
|
|
for cure_effect in item_def.effects['cures']:
|
|
if await db.remove_effect(current_player['id'], cure_effect):
|
|
effects_applied.append(f"Cured {cure_effect}")
|
|
|
|
if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0:
|
|
item_effectiveness = current_player_stats.get('item_effectiveness', 1.0)
|
|
restore_amount = int(item_def.effects['hp_restore'] * item_effectiveness)
|
|
new_hp = min(current_player['max_hp'], current_player['hp'] + restore_amount)
|
|
actual_heal = new_hp - current_player['hp']
|
|
if actual_heal > 0:
|
|
await db.update_player(current_player['id'], hp=new_hp)
|
|
current_player['hp'] = new_hp
|
|
effects_applied.append(get_game_message('healed_amount_text', locale, amount=actual_heal))
|
|
messages.append(create_combat_message("item_heal", origin="player", heal=actual_heal))
|
|
|
|
if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0:
|
|
item_effectiveness = current_player_stats.get('item_effectiveness', 1.0)
|
|
restore_amount = int(item_def.effects['stamina_restore'] * item_effectiveness)
|
|
new_stamina = min(current_player['max_stamina'], current_player['stamina'] + restore_amount)
|
|
actual_restore = new_stamina - current_player['stamina']
|
|
if actual_restore > 0:
|
|
await db.update_player_stamina(current_player['id'], new_stamina)
|
|
effects_applied.append(f"Restored {actual_restore} stamina")
|
|
messages.append(create_combat_message("item_restore", origin="player", stat="stamina", amount=actual_restore))
|
|
|
|
if inv_item['quantity'] > 1:
|
|
await db.update_inventory_quantity(inv_item['id'], inv_item['quantity'] - 1)
|
|
else:
|
|
await db.remove_from_inventory(inv_item['id'])
|
|
|
|
messages.append(create_combat_message(
|
|
"use_item", origin="player", item_name=item_name,
|
|
message=get_game_message('used_item_text', locale, name=item_name) + " (" + ", ".join(effects_applied) + ")"
|
|
))
|
|
last_action_text = f"{current_player['name']} used {item_name}!"
|
|
|
|
# End of turn swap
|
|
updates = {
|
|
'turn': 'defender' if is_attacker else 'attacker',
|
|
'turn_started_at': time.time(),
|
|
'last_action': f"{last_action_text}|{time.time()}"
|
|
}
|
|
await db.update_pvp_combat(pvp_combat['id'], updates)
|
|
|
|
elif req.action == 'flee':
|
|
# 50% chance to flee from PvP
|
|
if random.random() < 0.5:
|
|
last_action_text = get_game_message('flee_success_text', locale, name=current_player['name'])
|
|
messages.append(create_combat_message(
|
|
"flee_success",
|
|
origin="player",
|
|
message=last_action_text
|
|
))
|
|
|
|
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"{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:
|
|
# Failed to flee, skip turn
|
|
last_action_text = get_game_message('flee_fail_text', locale, name=current_player['name'])
|
|
messages.append(create_combat_message(
|
|
"flee_fail",
|
|
origin="player",
|
|
reason="chance",
|
|
message=last_action_text
|
|
))
|
|
|
|
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 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'],
|
|
"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_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)
|
|
}
|
|
|
|
# Determine which player object to send as "player" data for global state updates
|
|
player_data = {
|
|
"id": fresh_attacker['id'],
|
|
"username": fresh_attacker['name'],
|
|
"level": fresh_attacker['level'],
|
|
"hp": fresh_attacker['hp'],
|
|
"max_hp": fresh_attacker['max_hp'],
|
|
"xp": fresh_attacker['xp'],
|
|
"max_xp": fresh_attacker['level'] * 1000
|
|
} if is_attacker else {
|
|
"id": fresh_defender['id'],
|
|
"username": fresh_defender['name'],
|
|
"level": fresh_defender['level'],
|
|
"hp": fresh_defender['hp'],
|
|
"max_hp": fresh_defender['max_hp'],
|
|
"xp": fresh_defender['xp'],
|
|
"max_xp": fresh_defender['level'] * 1000
|
|
}
|
|
|
|
# Process messages for this player
|
|
# Use actor_id (current_player['id']) to identify who performed the action
|
|
# If I am NOT the actor, then the action was done BY an enemy against me.
|
|
# So I swap 'player' origin (Actor) to 'enemy' origin (Attacker from my perspective).
|
|
actor_id = current_player['id']
|
|
|
|
import copy
|
|
player_messages = []
|
|
is_actor = (player_id == actor_id)
|
|
|
|
# For the victim (non-actor), we strip the pre-generated text messages so frontend can generate
|
|
# "Enemy hit you" instead of "Alice hit Bob"
|
|
|
|
if not is_actor:
|
|
msgs_copy = copy.deepcopy(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
|
|
|
|
# Send separate payloads
|
|
# For actor: keep full text
|
|
# For victim: strip main message text so frontend uses data to render "Enemy hit you"
|
|
|
|
payload_data = {
|
|
"message": last_action_text if is_actor else None, # key refactor: hide text for victim
|
|
"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
|
|
}
|
|
|
|
await manager.send_personal_message(player_id, {
|
|
"type": "combat_update",
|
|
"data": payload_data,
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"messages": messages,
|
|
"combat_over": combat_over,
|
|
"winner_id": winner_id,
|
|
"pvp_combat": updated_pvp
|
|
} |