Files
echoes-of-the-ash/api/routers/combat.py
2026-02-05 15:00:49 +01:00

1548 lines
63 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
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
"""Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
redis_manager = redis_mgr
router = APIRouter(tags=["combat"])
# Endpoints
@router.get("/api/game/combat")
async def get_combat_status(current_user: dict = Depends(get_current_user)):
"""Get current combat status"""
combat = await db.get_active_combat(current_user['id'])
if not combat:
return {"in_combat": False}
# Load NPC data from npcs.json
import sys
sys.path.insert(0, '/app')
from data.npcs import NPCS
npc_def = NPCS.get(combat['npc_id'])
# Calculate time remaining for turn (server-side to avoid clock offset)
import time
turn_time_remaining = None
if combat['turn'] == 'player':
turn_started_at = combat.get('turn_started_at', 0)
time_elapsed = time.time() - turn_started_at
turn_time_remaining = max(0, 300 - time_elapsed) # 5 minutes = 300 seconds
return {
"in_combat": True,
"combat": {
"npc_id": combat['npc_id'],
"npc_name": npc_def.name if npc_def else combat['npc_id'].replace('_', ' ').title(),
"npc_hp": combat['npc_hp'],
"npc_max_hp": combat['npc_max_hp'],
"npc_image": f"{npc_def.image_path}" if npc_def else None,
"turn": combat['turn'],
"round": combat.get('round', 1),
"turn_time_remaining": turn_time_remaining
}
}
@router.post("/api/game/combat/initiate")
async def initiate_combat(
req: InitiateCombatRequest,
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'])
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(player['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
base_damage = 5
strength_bonus = player['strength'] // 2
level_bonus = player['level']
weapon_damage = 0
weapon_effects = {}
weapon_inv_id = None
# Check for equipped weapon
equipment = await db.get_all_equipment(player['id'])
if equipment.get('weapon') and equipment['weapon']:
weapon_slot = equipment['weapon']
inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id'])
if inv_item:
weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
if weapon_def and weapon_def.stats:
weapon_damage = random.randint(
weapon_def.stats.get('damage_min', 0),
weapon_def.stats.get('damage_max', 0)
)
weapon_effects = weapon_def.weapon_effects if hasattr(weapon_def, 'weapon_effects') else {}
weapon_inv_id = weapon_slot['item_id']
# Check encumbrance penalty (higher encumbrance = chance to miss)
encumbrance = player.get('encumbrance', 0)
attack_failed = False
if encumbrance > 0:
miss_chance = min(0.3, encumbrance * 0.05) # Max 30% miss chance
if random.random() < miss_chance:
attack_failed = True
variance = random.randint(-2, 2)
damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
if attack_failed:
messages.append(create_combat_message(
"player_miss",
origin="player",
reason="encumbrance"
))
new_npc_hp = combat['npc_hp']
else:
# Apply damage to NPC
new_npc_hp = max(0, combat['npc_hp'] - damage)
messages.append(create_combat_message(
"player_attack",
origin="player",
damage=damage
))
# Apply weapon effects
if weapon_effects and 'bleeding' in weapon_effects:
bleeding = weapon_effects['bleeding']
if random.random() < bleeding.get('chance', 0):
# Apply bleeding effect (would need combat effects table, for now just bonus damage)
bleed_damage = bleeding.get('damage', 0)
new_npc_hp = max(0, new_npc_hp - bleed_damage)
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)
)
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 == '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 == 'defend':
# Apply "defending" status effect - reduces incoming damage by 50% for 1 turn
await db.add_effect(
player_id=player['id'],
effect_name='defending',
effect_icon='🛡️',
effect_type='buff',
value=50, # 50% damage reduction
ticks_remaining=1,
persist_after_combat=False,
source='action:defend'
)
messages.append(create_combat_message(
"defend",
origin="player",
message=get_game_message('defend_text', locale, name=player['name'])
))
# NPC's turn after defend
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
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']
await db.add_effect(
player_id=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, # 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:
hp_restore = item_def.effects['hp_restore']
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'):
stamina_restore = item_def.effects['stamina_restore']
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']
}
}
@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
messages = []
combat_over = False
winner_id = None
# Track the last action string for DB history
last_action_text = ""
if req.action == 'attack':
# Calculate damage (similar to PvE)
base_damage = 5
strength_bonus = current_player['strength'] * 2
level_bonus = current_player['level']
# Check for equipped weapon
weapon_damage = 0
equipment = await db.get_all_equipment(current_player['id'])
if equipment.get('weapon') and equipment['weapon']:
weapon_slot = equipment['weapon']
inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id'])
if inv_item:
weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
if weapon_def and weapon_def.stats:
weapon_damage = random.randint(
weapon_def.stats.get('damage_min', 0),
weapon_def.stats.get('damage_max', 0)
)
# Decrease weapon durability
if inv_item.get('unique_item_id'):
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
if new_durability is None:
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 + strength_bonus + level_bonus + weapon_damage + variance)
# Apply armor reduction and durability loss to opponent
armor_absorbed, broken_armor = await reduce_armor_durability(opponent['id'], damage)
actual_damage = max(1, damage - armor_absorbed)
# Structure the attack message
messages.append(create_combat_message(
"player_attack",
origin="player",
damage=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 == '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
}