From d5afd28eb9976acdd152cafa9f1ca7f76853faa0 Mon Sep 17 00:00:00 2001 From: Joan Date: Wed, 25 Feb 2026 12:10:45 +0100 Subject: [PATCH] Refactor: unified combat engine for PvE/PvP - Create api/services/combat_engine.py with all shared combat logic - Rewrite combat.py from 2820 to ~600 lines (thin orchestration) - Fix buff consumption: fortify, berserker_rage, evade, foresight, iron_skin now actually work - Fix stun: PvE skills now write stun to npc_status_effects - Fix skill damage: now uses stats.attack_power consistently (includes perks) - Fix PvPCombatActionRequest: add skill_id field for proper PvP skill support - Remove dead code: PvP skill/item blocks copy-pasted into PvE endpoint - Update game_logic.npc_attack to check buff modifiers (dodge, damage reduction, etc.) --- api/game_logic.py | 49 +- api/routers/combat.py | 2587 ++++++--------------------------- api/services/combat_engine.py | 1213 ++++++++++++++++ api/services/models.py | 5 +- 4 files changed, 1693 insertions(+), 2161 deletions(-) create mode 100644 api/services/combat_engine.py diff --git a/api/game_logic.py b/api/game_logic.py index d892d00..869e9f8 100644 --- a/api/game_logic.py +++ b/api/game_logic.py @@ -810,17 +810,58 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p )) # Remove defending effect after use await db.remove_effect(player_id, 'defending') + + # ── Check buff-based damage reduction (fortify) ── + buff_dmg_reduction = 0.0 + if player_stats: + buff_dmg_reduction = player_stats.get('buff_damage_reduction', 0.0) + if buff_dmg_reduction > 0: + npc_damage = max(1, int(npc_damage * (1 - buff_dmg_reduction))) + messages.append(create_combat_message( + "damage_reduced", + origin="player", + reduction=int(buff_dmg_reduction * 100) + )) + + # ── Check berserker rage increased damage taken ── + buff_dmg_taken_increase = 0.0 + if player_stats: + buff_dmg_taken_increase = player_stats.get('buff_damage_taken_increase', 0.0) + if buff_dmg_taken_increase > 0: + npc_damage = int(npc_damage * (1 + buff_dmg_taken_increase)) - # Check for dodge + # ── Check guaranteed dodge from Evade buff ── dodged = False - if player_stats and 'dodge_chance' in player_stats: + if player_stats and player_stats.get('buff_guaranteed_dodge', False): + dodged = True + messages.append(create_combat_message( + "combat_dodge", + origin="player" + )) + actual_damage = 0 + new_player_hp = player['hp'] + # Consume the evade buff + await db.remove_effect(player_id, 'evade') + + # ── Check Foresight buff (enemy misses) ── + if not dodged and player_stats and player_stats.get('buff_enemy_miss', False): + dodged = True + messages.append(create_combat_message( + "combat_dodge", + origin="player" + )) + actual_damage = 0 + new_player_hp = player['hp'] + # Foresight ticks down naturally via db.tick_player_effects + + # Check for regular dodge (stat-based) + if not dodged and player_stats and 'dodge_chance' in player_stats: if random.random() < player_stats['dodge_chance']: dodged = True messages.append(create_combat_message( "combat_dodge", origin="player" )) - # Prevent damage calculation actual_damage = 0 new_player_hp = player['hp'] @@ -833,7 +874,6 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p "combat_block", origin="player" )) - # Apply blocked effect (damage reduced significantly or nullified) npc_damage = max(1, int(npc_damage * 0.2)) # Block mitigates 80% damage if not dodged: @@ -844,7 +884,6 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p if player_stats and player_stats.get('armor_reduction', 0) > 0: pct_reduction = player_stats['armor_reduction'] actual_damage = max(1, int(npc_damage * (1 - pct_reduction))) - # Still show "armor_absorbed" conceptually for UI logs, though it's % based now armor_absorbed_visual = npc_damage - actual_damage else: actual_damage = max(1, npc_damage - armor_absorbed) diff --git a/api/routers/combat.py b/api/routers/combat.py index b304345..0f70d2f 100644 --- a/api/routers/combat.py +++ b/api/routers/combat.py @@ -1,6 +1,8 @@ """ Combat router. -Auto-generated from main.py migration. +Thin orchestration layer — all combat logic lives in services/combat_engine.py. +PvE uses REST responses + location WebSocket broadcasts. +PvP uses REST responses + personal WebSocket messages to both players. """ from fastapi import APIRouter, HTTPException, Depends, status, Request from fastapi.security import HTTPAuthorizationCredentials @@ -8,6 +10,8 @@ from typing import Optional, Dict, Any from datetime import datetime import random import json +import copy +import time import logging from ..services.constants import PVP_TURN_TIMEOUT @@ -19,6 +23,7 @@ from ..items import ItemsManager from .. import game_logic from ..core.websockets import manager from .equipment import reduce_armor_durability +from ..services import combat_engine logger = logging.getLogger(__name__) @@ -42,8 +47,9 @@ def init_router_dependencies(locations, items_manager, world, redis_mgr=None, qu router = APIRouter(tags=["combat"]) - -# Endpoints +# ============================================================================ +# PvE ENDPOINTS +# ============================================================================ @router.get("/api/game/combat") async def get_combat_status(current_user: dict = Depends(get_current_user)): @@ -52,19 +58,17 @@ async def get_combat_status(current_user: dict = Depends(get_current_user)): 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 + # Calculate time remaining for turn turn_time_remaining = None if combat['turn'] == 'player': turn_started_at = combat.get('turn_started_at', 0) time_elapsed = time.time() - turn_started_at - turn_time_remaining = max(0, 300 - time_elapsed) # 5 minutes = 300 seconds + turn_time_remaining = max(0, 300 - time_elapsed) return { "in_combat": True, @@ -88,21 +92,16 @@ async def initiate_combat( 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" - ) + 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: @@ -112,18 +111,11 @@ async def initiate_combat( enemy = result.fetchone() if not enemy: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Enemy not found" - ) + 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" - ) + 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) @@ -138,20 +130,19 @@ async def initiate_combat( from_wandering=True ) - # Remove the wandering enemy from the location + # Remove wandering enemy async with db.DatabaseSession() as session: from sqlalchemy import delete stmt = delete(db.wandering_enemies).where(db.wandering_enemies.c.id == req.enemy_id) await session.execute(stmt) await session.commit() - # Track combat initiation + # Track stats 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 + player = current_user - # Send WebSocket update to the player + # WebSocket to player await manager.send_personal_message(current_user['id'], { "type": "combat_started", "data": { @@ -169,7 +160,7 @@ async def initiate_combat( "timestamp": datetime.utcnow().isoformat() }) - # Broadcast to location that player entered combat + # Broadcast to location await manager.send_to_location( location_id=current_user['location_id'], message={ @@ -205,340 +196,98 @@ async def combat_action( request: Request, current_user: dict = Depends(get_current_user) ): - """Perform a combat action""" - import random + """Perform a PvE combat action — delegates to combat_engine""" 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" - ) + 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" - ) + 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 + # Anti rapid-fire check current_round = combat.get('round', 1) - - if current_round > 1: # Only check after first turn + if current_round > 1: 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" - ) + if time.time() - turn_started_at < 2.0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Please wait for the enemy's turn to complete") - # Get player and NPC data - player = current_user # current_user is already the character dict + player = current_user npc_def = NPCS.get(combat['npc_id']) - # Get player derived stats + # Get derived stats from ..services.stats import calculate_derived_stats stats = await calculate_derived_stats(player['id'], redis_manager) messages = [] combat_over = False + player_won = False + quest_updates = [] - # Process status effects (bleeding, etc.) before action - active_effects = await db.tick_player_effects(player['id']) + # ── Process status effects before action ── + effect_msgs, player_hp, player_died = await combat_engine.process_status_effects( + player['id'], player, stats['max_hp'] + ) + messages.extend(effect_msgs) + player['hp'] = player_hp - # 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 player_died: + await db.remove_non_persistent_effects(player['id']) + await db.end_combat(player['id']) + return { + "player": player, + "combat": None, + "messages": messages + [create_combat_message("died", origin="player", message="You died from status effects!")], + "active_effects": [], + "round": combat['round'] + } - + # Build target dict for the engine + npc_target = { + 'id': combat['npc_id'], + 'hp': combat['npc_hp'], + 'max_hp': combat['npc_max_hp'], + 'defense': getattr(npc_def, 'defense', 0), + 'name': npc_def.name, + 'type': 'npc', + } + + # ── ATTACK ── if req.action == 'attack': - # Calculate player damage using derived stats - base_damage = stats.get('attack_power', 5) - weapon_effects = {} - weapon_inv_id = None + result = await combat_engine.execute_attack( + attacker_id=player['id'], + attacker=player, + attacker_stats=stats, + target=npc_target, + is_pvp=False, + items_manager=ITEMS_MANAGER, + reduce_armor_func=reduce_armor_durability, + ) + messages.extend(result['messages']) + new_npc_hp = result['target_hp'] - # 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 - )) + if result['target_defeated']: + # Victory + 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) + victory = await combat_engine.handle_victory_pve( + player, combat, npc_def, ITEMS_MANAGER, QUESTS_DATA, redis_manager, locale ) - - # --- 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}") - # ----------------------------- - # ----------------------------- + messages.extend(victory['messages']) + quest_updates = victory.get('quest_updates', []) - + # Track damage stat + await db.update_player_statistics(player['id'], damage_dealt=result['damage_dealt'], increment=True) - 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 + # Broadcast await manager.send_to_location( location_id=player['location_id'], message={ @@ -553,427 +302,179 @@ async def combat_action( }, 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 + # NPC turn + npc_msgs, player_defeated = await combat_engine.execute_npc_turn( + player['id'], + {'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp'], + 'npc_intent': combat.get('npc_intent', 'attack'), + 'npc_status_effects': combat.get('npc_status_effects', '')}, + npc_def, + reduce_armor_durability, + redis_manager ) - messages.extend(npc_attack_messages) + messages.extend(npc_msgs) 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 - }) + await db.update_combat(player['id'], {'npc_hp': new_npc_hp}) - + # ── SKILL ── elif req.action == 'skill': - if not req.item_id: - raise HTTPException(status_code=400, detail="skill_id (passed as item_id) required") + if not req.skill_id: + raise HTTPException(status_code=400, detail="skill_id required for skill action") - 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 + result = await combat_engine.execute_skill( + player_id=player['id'], + player=player, + player_stats=stats, + target=npc_target, + skill_id=req.skill_id, + combat_state=combat, + is_pvp=False, + items_manager=ITEMS_MANAGER, + reduce_armor_func=reduce_armor_durability, + redis_manager=redis_manager, + ) - # 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 + if result.get('error'): + raise HTTPException(status_code=result.get('status_code', 400), detail=result['error']) + + messages.extend(result['messages']) + new_npc_hp = result['target_hp'] + + if result['target_defeated']: + # Victory via skill + messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name)) + combat_over = True + player_won = True + + victory = await combat_engine.handle_victory_pve( + player, combat, npc_def, ITEMS_MANAGER, QUESTS_DATA, redis_manager, locale ) + messages.extend(victory['messages']) + quest_updates = victory.get('quest_updates', []) - # 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}" + # Broadcast + await manager.send_to_location( + location_id=player['location_id'], + message={ + "type": "location_update", + "data": { + "message": get_game_message('player_defeated_enemy_broadcast', locale, player_name=player['name'], npc_name=get_locale_string(npc_def.name, locale)), + "action": "combat_ended", + "player_id": player['id'], + "corpse_created": True + }, + "timestamp": datetime.utcnow().isoformat() + }, + exclude_player_id=player['id'] ) - 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) + # NPC turn after skill + npc_msgs, player_defeated = await combat_engine.execute_npc_turn( + player['id'], + {'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp'], + 'npc_intent': combat.get('npc_intent', 'attack'), + 'npc_status_effects': combat.get('npc_status_effects', '')}, + npc_def, + reduce_armor_durability, + redis_manager + ) + messages.extend(npc_msgs) + if player_defeated: + await db.remove_non_persistent_effects(player['id']) + combat_over = True + + # ── USE ITEM ── elif req.action == 'use_item': if not req.item_id: raise HTTPException(status_code=400, detail="item_id required for use_item action") - - 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 = [] + result = await combat_engine.execute_use_item( + player_id=player['id'], + player=player, + player_stats=stats, + item_id=req.item_id, + combat_state=combat, + target=npc_target, + is_pvp=False, + items_manager=ITEMS_MANAGER, + locale=locale, + ) - if 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}!" + if result.get('error'): + raise HTTPException(status_code=result.get('status_code', 400), detail=result['error']) - # 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']) - )) + messages.extend(result['messages']) + + if result['target_defeated']: + # Victory via item (throwable) combat_over = True - player_won = False # Fled, not won + player_won = True - # Track successful flee - await db.update_player_statistics(player['id'], successful_flees=1, increment=True) + victory = await combat_engine.handle_victory_pve( + player, combat, npc_def, ITEMS_MANAGER, QUESTS_DATA, redis_manager, locale + ) + messages.extend(victory['messages']) + quest_updates = victory.get('quest_updates', []) + elif not combat_over: + # NPC turn after item use + npc_msgs, player_defeated = await combat_engine.execute_npc_turn( + player['id'], + {'npc_hp': result.get('target_hp', combat['npc_hp']), 'npc_max_hp': combat['npc_max_hp'], + 'npc_intent': combat.get('npc_intent', 'attack'), + 'npc_status_effects': combat.get('npc_status_effects', '')}, + npc_def, + reduce_armor_durability, + redis_manager + ) + messages.extend(npc_msgs) - # 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 + if player_defeated: + await db.remove_non_persistent_effects(player['id']) + combat_over = True + else: + # Update NPC HP from throwable damage + if result.get('target_hp') is not None and result['target_hp'] != combat['npc_hp']: + await db.update_combat(player['id'], {'npc_hp': result['target_hp']}) + + # ── FLEE ── + elif req.action == 'flee': + result = await combat_engine.execute_flee_pve( + player_id=player['id'], + player=player, + player_stats=stats, + combat=combat, + npc_def=npc_def, + reduce_armor_func=reduce_armor_durability, + locale=locale, + ) + messages.extend(result['messages']) + combat_over = result['combat_over'] + player_won = result.get('success', False) + + if result.get('corpse_data'): + # Broadcast death + broadcast_data = { + "message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']), + "action": "player_died", + "player_id": player['id'] + } + broadcast_data["corpse"] = result['corpse_data'] + await manager.send_to_location( + location_id=combat['location_id'], + message={ + "type": "location_update", + "data": broadcast_data, + "timestamp": datetime.utcnow().isoformat() + }, + exclude_player_id=player['id'] + ) + elif result.get('success'): + # Broadcast flee await manager.send_to_location( location_id=combat['location_id'], message={ @@ -987,697 +488,16 @@ async def combat_action( }, 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 + # ── Build response ── 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) + turn_time_remaining = max(0, 300 - (time.time() - turn_started_at)) updated_combat = { "npc_id": raw_combat['npc_id'], @@ -1690,13 +510,9 @@ async def combat_action( "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. + updated_player = current_user return { "success": True, @@ -1710,10 +526,14 @@ async def combat_action( "xp": updated_player['xp'], "level": updated_player['level'] }, - "quest_updates": quest_updates if 'quest_updates' in locals() else [] + "quest_updates": quest_updates } +# ============================================================================ +# PvP ENDPOINTS +# ============================================================================ + @router.post("/api/game/pvp/initiate") async def initiate_pvp_combat( req: PvPCombatInitiateRequest, @@ -1721,15 +541,13 @@ async def initiate_pvp_combat( 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 + # Validation checks existing_combat = await db.get_active_combat(attacker['id']) if existing_combat: raise HTTPException(status_code=400, detail="You are already in PvE combat") @@ -1738,12 +556,10 @@ async def initiate_pvp_combat( 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") @@ -1752,23 +568,18 @@ async def initiate_pvp_combat( 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']})" - ) + 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 + # Create combat pvp_combat = await db.create_pvp_combat( attacker_id=attacker['id'], defender_id=req.target_player_id, @@ -1776,10 +587,9 @@ async def initiate_pvp_combat( 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 + # WebSocket to both players await manager.send_personal_message(attacker['id'], { "type": "combat_started", "data": { @@ -1798,7 +608,7 @@ async def initiate_pvp_combat( "timestamp": datetime.utcnow().isoformat() }) - # Broadcast to location that PvP combat started - both players should be removed from view + # Broadcast to location await manager.send_to_location( attacker['location_id'], { @@ -1807,11 +617,11 @@ async def initiate_pvp_combat( "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 + "player_left_ids": [attacker['id'], defender['id']] }, "timestamp": datetime.utcnow().isoformat() }, - exclude_player_id=None # Send to everyone including combatants + exclude_player_id=None ) return { @@ -1828,29 +638,22 @@ async def get_pvp_combat_status(current_user: dict = Depends(get_current_user)): 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, @@ -1868,7 +671,7 @@ async def get_pvp_combat_status(current_user: dict = Depends(get_current_user)): "id": attacker['id'], "username": attacker['name'], "level": attacker['level'], - "hp": attacker['hp'], # Use actual player HP + "hp": attacker['hp'], "max_hp": attacker['max_hp'], "image": "/images/characters/default.webp" }, @@ -1876,7 +679,7 @@ async def get_pvp_combat_status(current_user: dict = Depends(get_current_user)): "id": defender['id'], "username": defender['name'], "level": defender['level'], - "hp": defender['hp'], # Use actual player HP + "hp": defender['hp'], "max_hp": defender['max_hp'], "image": "/images/characters/default.webp" }, @@ -1894,9 +697,6 @@ async def get_pvp_combat_status(current_user: dict = Depends(get_current_user)): } -class PvPAcknowledgeRequest(BaseModel): - combat_id: int - @router.post("/api/game/pvp/acknowledge") async def acknowledge_pvp_combat( @@ -1907,11 +707,8 @@ async def acknowledge_pvp_combat( """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 + player = current_user if player: await manager.send_to_location( location_id=player['location_id'], @@ -1930,10 +727,6 @@ async def acknowledge_pvp_combat( 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( @@ -1941,11 +734,7 @@ async def pvp_combat_action( request: Request, current_user: dict = Depends(get_current_user) ): - """Perform a PvP combat action""" - import random - import time - - # Extract locale + """Perform a PvP combat action — delegates to combat_engine""" locale = request.headers.get('Accept-Language', 'en') # Get PvP combat @@ -1967,755 +756,269 @@ async def pvp_combat_action( current_player = attacker if is_attacker else defender opponent = defender if is_attacker else attacker - # Get derived stats for both players + # Get derived stats 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) + 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']) + # ── Process status effects ── + effect_msgs, player_hp, player_died = await combat_engine.process_status_effects( + current_player['id'], current_player, current_player_stats.get('max_hp', current_player['max_hp']) + ) + messages.extend(effect_msgs) + current_player['hp'] = player_hp - if active_effects: - from ..game_logic import calculate_status_impact - total_impact = calculate_status_impact(active_effects) + if player_died: + messages.append(create_combat_message("died", origin="player", message="You died from status effects!")) + combat_over = True + winner_id = opponent['id'] + await db.update_player(current_player['id'], hp=0, is_dead=True) - 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" - )) + # Create corpse + corpse_data = await combat_engine._create_player_corpse(current_player['id'], current_player, current_player['location_id'], ITEMS_MANAGER) + + await db.update_player_statistics(current_player['id'], pvp_deaths=1, pvp_combats_lost=1, increment=True) + await db.update_player_statistics(opponent['id'], players_killed=1, pvp_combats_won=1, increment=True) + + # Broadcast + broadcast_data = { + "message": get_game_message('pvp_defeat_broadcast', locale, opponent=current_player['name'], winner=opponent['name']), + "action": "player_died", + "player_id": current_player['id'] + } + if corpse_data: + broadcast_data["corpse"] = corpse_data + await manager.send_to_location( + location_id=current_player['location_id'], + message={"type": "location_update", "data": broadcast_data, "timestamp": datetime.utcnow().isoformat()} + ) + await db.end_pvp_combat(pvp_combat['id']) - # Stop processing action if player died from status effects - if combat_over: - pass + # ── ATTACK ── elif req.action == 'attack': - # Calculate damage (unified formula with derived stats) - base_damage = current_player_stats.get('attack_power', 5) + # Build target with opponent's stats for dodge/block + pvp_target = { + 'id': opponent['id'], + 'hp': opponent['hp'], + 'max_hp': opponent['max_hp'], + 'defense': 0, + 'name': opponent['name'], + 'type': 'player', + } - # 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') + result = await combat_engine.execute_attack( + attacker_id=current_player['id'], + attacker=current_player, + attacker_stats=current_player_stats, + target=pvp_target, + is_pvp=True, + items_manager=ITEMS_MANAGER, + reduce_armor_func=reduce_armor_durability, + ) + messages.extend(result['messages']) - 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 + new_opponent_hp = result['target_hp'] 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})" + last_action_text = f"{current_player['name']} attacks {opponent['name']} for {result['damage_dealt']} damage!" - 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: + if result['target_defeated']: 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 + pvp_victory = await combat_engine.handle_victory_pvp( + current_player, opponent, result['damage_dealt'], ITEMS_MANAGER, locale ) + messages.extend(pvp_victory['messages']) - # 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 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 - + if pvp_victory.get('corpse_data'): + broadcast_data["corpse"] = pvp_victory['corpse_data'] await manager.send_to_location( location_id=opponent['location_id'], - message={ - "type": "location_update", - "data": broadcast_data, - "timestamp": datetime.utcnow().isoformat() - } + 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 stats and switch turns + await db.update_player_statistics(current_player['id'], pvp_damage_dealt=result['damage_dealt'], pvp_attacks_landed=1, increment=True) + await db.update_player_statistics(opponent['id'], pvp_damage_taken=result['damage_dealt'], pvp_attacks_received=1, increment=True) - # 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 = { + 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()}" # 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) - + 'last_action': f"{last_action_text}|{time.time()}" + }) + # ── SKILL ── elif req.action == 'skill': - if not req.item_id: - raise HTTPException(status_code=400, detail="skill_id (passed as item_id) required") + skill_id = req.skill_id or req.item_id # Support legacy item_id field + if not skill_id: + raise HTTPException(status_code=400, detail="skill_id required") - 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 + pvp_target = { + 'id': opponent['id'], + 'hp': opponent['hp'], + 'max_hp': opponent['max_hp'], + 'defense': 0, + 'name': opponent['name'], + 'type': 'player', + } - # 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 + result = await combat_engine.execute_skill( + player_id=current_player['id'], + player=current_player, + player_stats=current_player_stats, + target=pvp_target, + skill_id=skill_id, + combat_state={}, # No PvE combat state for PvP + is_pvp=True, + items_manager=ITEMS_MANAGER, + reduce_armor_func=reduce_armor_durability, + redis_manager=redis_manager, + ) - # 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!" + if result.get('error'): + raise HTTPException(status_code=result.get('status_code', 400), detail=result['error']) + + messages.extend(result['messages']) + damage_done = result['damage_dealt'] + last_action_text = f"{current_player['name']} used a skill!" - # 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: + await db.update_player(opponent['id'], hp=result['target_hp']) + last_action_text = f"{current_player['name']} dealt {damage_done} damage with a skill!" + + if result['target_defeated']: last_action_text += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!" - 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) + pvp_victory = await combat_engine.handle_victory_pvp( + current_player, opponent, damage_done, ITEMS_MANAGER, locale + ) + messages.extend(pvp_victory['messages']) - # 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 - + if pvp_victory.get('corpse_data'): + broadcast_data["corpse"] = pvp_victory['corpse_data'] await manager.send_to_location( location_id=opponent['location_id'], - message={ - "type": "location_update", - "data": broadcast_data, - "timestamp": datetime.utcnow().isoformat() - } + 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) + await db.update_player_statistics(current_player['id'], pvp_damage_dealt=damage_done, pvp_attacks_landed=1, increment=True) + await db.update_player_statistics(opponent['id'], pvp_damage_taken=damage_done, pvp_attacks_received=1, increment=True) - # End of turn swap - updates = { + 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_pvp_combat(pvp_combat['id'], updates) - + }) else: - # Skill didn't do damage, but turn still ends - updates = { + # Non-damage skill (heal, buff) — switch turns + await db.update_pvp_combat(pvp_combat['id'], { 'turn': 'defender' if is_attacker else 'attacker', 'turn_started_at': time.time(), 'last_action': f"{last_action_text}|{time.time()}" - } - await db.update_pvp_combat(pvp_combat['id'], updates) - + }) + + # ── USE ITEM ── elif req.action == 'use_item': if not req.item_id: raise HTTPException(status_code=400, detail="item_id required for use_item action") - - 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 = [] + pvp_target = { + 'id': opponent['id'], + 'hp': opponent['hp'], + 'max_hp': opponent['max_hp'], + 'defense': 0, + 'name': opponent['name'], + 'type': 'player', + } - 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}!" + result = await combat_engine.execute_use_item( + player_id=current_player['id'], + player=current_player, + player_stats=current_player_stats, + item_id=req.item_id, + combat_state={}, + target=pvp_target, + is_pvp=True, + items_manager=ITEMS_MANAGER, + locale=locale, + ) - # End of turn swap - updates = { + if result.get('error'): + raise HTTPException(status_code=result.get('status_code', 400), detail=result['error']) + + messages.extend(result['messages']) + last_action_text = f"{current_player['name']} used an item!" + + # Switch turns + await db.update_pvp_combat(pvp_combat['id'], { 'turn': 'defender' if is_attacker else 'attacker', 'turn_started_at': time.time(), 'last_action': f"{last_action_text}|{time.time()}" - } - await db.update_pvp_combat(pvp_combat['id'], updates) + }) + # ── FLEE ── 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 - )) - + result = await combat_engine.execute_flee_pvp( + player_id=current_player['id'], + player=current_player, + player_stats=current_player_stats, + locale=locale, + ) + messages.extend(result['messages']) + last_action_text = result.get('last_action', '') + + if result['success']: combat_over = True - - # 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 - ) + 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 + await db.update_player_statistics(current_player['id'], pvp_failed_flees=1, increment=True) + + # ── Send WebSocket updates to both players ── updated_pvp = await db.get_pvp_combat_by_id(pvp_combat['id']) - - # 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') + is_att = player_id == pvp_combat['attacker_character_id'] + your_t = (is_att and updated_pvp['turn'] == 'attacker') or \ + (not is_att and updated_pvp['turn'] == 'defender') - # 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) + time_rem = 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": { @@ -2734,10 +1037,10 @@ async def pvp_combat_action( "max_hp": fresh_defender['max_hp'], "image": "/images/characters/default.webp" }, - "is_attacker": is_attacker, - "your_turn": your_turn, + "is_attacker": is_att, + "your_turn": your_t, "current_turn": updated_pvp['turn'], - "time_remaining": int(time_remaining), + "time_remaining": int(time_rem), "location_id": updated_pvp['location_id'], "last_action": updated_pvp.get('last_action'), "combat_over": updated_pvp.get('attacker_fled', False) or updated_pvp.get('defender_fled', False) or \ @@ -2746,40 +1049,22 @@ async def pvp_combat_action( "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 + "id": fresh_attacker['id'] if is_att else fresh_defender['id'], + "username": fresh_attacker['name'] if is_att else fresh_defender['name'], + "level": fresh_attacker['level'] if is_att else fresh_defender['level'], + "hp": fresh_attacker['hp'] if is_att else fresh_defender['hp'], + "max_hp": fresh_attacker['max_hp'] if is_att else fresh_defender['max_hp'], + "xp": fresh_attacker['xp'] if is_att else fresh_defender['xp'], + "max_xp": (fresh_attacker['level'] if is_att else fresh_defender['level']) * 1000 } - # 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) + player_messages = [] for m in msgs_copy: if m.get('origin') == 'player': m['origin'] = 'enemy' @@ -2789,25 +1074,19 @@ async def pvp_combat_action( 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, + "data": { + "message": last_action_text if is_actor else None, + "log_entry": last_action_text if is_actor else None, + "pvp_combat": enriched_pvp, + "combat_over": combat_over, + "winner_id": winner_id, + "player": player_data, + "attacker_hp": fresh_attacker['hp'], + "defender_hp": fresh_defender['hp'], + "messages": player_messages + }, "timestamp": datetime.utcnow().isoformat() }) diff --git a/api/services/combat_engine.py b/api/services/combat_engine.py new file mode 100644 index 0000000..42b29bb --- /dev/null +++ b/api/services/combat_engine.py @@ -0,0 +1,1213 @@ +""" +Combat Engine - Shared combat logic for PvE and PvP. + +All combat actions (attack, skill, use_item, flee) go through this engine. +The engine returns structured results (messages, state changes) without +sending WebSocket messages or HTTP responses — that's the router's job. + +Target abstraction: + PvE: {id, hp, max_hp, defense, name, type: "npc", npc_def} + PvP: {id, hp, max_hp, defense: 0, name, type: "player"} +""" +import random +import time +import json +import logging +from typing import Dict, Any, List, Tuple, Optional + +from .. import database as db +from ..items import ItemsManager +from ..services.helpers import create_combat_message, get_locale_string, get_game_message +from ..services.stats import calculate_derived_stats + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# STATUS EFFECTS PROCESSING +# ============================================================================ + +async def process_status_effects(player_id: int, player: dict, max_hp: int) -> Tuple[List[dict], int, bool]: + """ + Tick and process all status effects on a player before their action. + + Returns: (messages, new_hp, player_died) + """ + messages = [] + current_hp = player['hp'] + player_died = False + + active_effects = await db.tick_player_effects(player_id) + if not active_effects: + return messages, current_hp, False + + from ..game_logic import calculate_status_impact + total_impact = calculate_status_impact(active_effects) + + if total_impact > 0: + # DAMAGE from effects + damage = total_impact + current_hp = max(0, current_hp - damage) + await db.update_player_hp(player_id, current_hp) + + messages.append(create_combat_message( + "effect_damage", origin="player", + damage=damage, effect_name="status effects" + )) + + if current_hp <= 0: + player_died = True + + elif total_impact < 0: + # HEALING from effects + heal = abs(total_impact) + new_hp = min(max_hp, current_hp + heal) + actual_heal = new_hp - current_hp + if actual_heal > 0: + current_hp = new_hp + await db.update_player_hp(player_id, current_hp) + messages.append(create_combat_message( + "effect_heal", origin="player", + heal=actual_heal, effect_name="status effects" + )) + + return messages, current_hp, player_died + + +# ============================================================================ +# CHECK ACTIVE BUFFS +# ============================================================================ + +async def get_active_buff_modifiers(player_id: int) -> Dict[str, Any]: + """ + Read active buff effects and return combat modifiers. + Consumes one-shot buffs (evade, foresight tick) where appropriate. + + Returns dict with: + - damage_reduction (float, 0-1): reduction on incoming damage + - damage_bonus (float, 0+): bonus multiplier on outgoing damage + - damage_taken_increase (float, 0+): increase on incoming damage + - guaranteed_dodge (bool): auto-dodge next attack + - enemy_miss (bool): enemy attacks miss + - status_immunity (bool): immune to new status effects + """ + effects = await db.get_player_effects(player_id) + modifiers = { + 'damage_reduction': 0.0, + 'damage_bonus': 0.0, + 'damage_taken_increase': 0.0, + 'guaranteed_dodge': False, + 'enemy_miss': False, + 'status_immunity': False, + } + + for eff in effects: + source = eff.get('source', '') + effect_name = eff.get('effect_name', '') + value = eff.get('value', 0) + + # Fortify: damage_reduction stored as value (e.g. 60 for 60%) + if 'fortify' in source: + modifiers['damage_reduction'] = max(modifiers['damage_reduction'], value / 100.0) + + # Berserker Rage: damage_bonus + damage_taken_increase + elif effect_name == 'berserker_rage': + modifiers['damage_bonus'] = 0.5 + modifiers['damage_taken_increase'] = 0.25 + + # Evade: guaranteed dodge + elif effect_name == 'evade': + modifiers['guaranteed_dodge'] = True + + # Foresight: enemy misses + elif effect_name == 'foresight': + modifiers['enemy_miss'] = True + + # Iron Skin: status immunity + elif effect_name == 'iron_skin': + modifiers['status_immunity'] = True + + return modifiers + + +# ============================================================================ +# ATTACK ACTION +# ============================================================================ + +async def execute_attack( + attacker_id: int, + attacker: dict, + attacker_stats: dict, + target: dict, + is_pvp: bool, + items_manager: ItemsManager, + reduce_armor_func, +) -> Dict[str, Any]: + """ + Execute a basic attack action. + + Returns: { + messages: list, damage_dealt: int, target_hp: int, + target_defeated: bool, weapon_broke: bool + } + """ + messages = [] + + # Get active buff modifiers + buff_mods = await get_active_buff_modifiers(attacker_id) + + # Base damage from derived stats (includes weapon + str + level + perks) + base_damage = attacker_stats.get('attack_power', 5) + + # Apply berserker rage damage bonus + if buff_mods['damage_bonus'] > 0: + base_damage = int(base_damage * (1 + buff_mods['damage_bonus'])) + + # Check encumbrance miss + encumbrance = attacker.get('encumbrance', 0) + if encumbrance > 0: + miss_chance = min(0.3, encumbrance * 0.05) + if random.random() < miss_chance: + messages.append(create_combat_message( + "player_miss", origin="player", reason="encumbrance" + )) + return { + 'messages': messages, 'damage_dealt': 0, + 'target_hp': target['hp'], 'target_defeated': False, + 'weapon_broke': False + } + + # Variance + variance = random.randint(-2, 2) + damage = max(1, base_damage + variance) + + # Critical hit + is_critical = False + crit_chance = attacker_stats.get('crit_chance', 0.05) + if random.random() < crit_chance: + is_critical = True + damage = int(damage * attacker_stats.get('crit_damage', 1.5)) + + # Weapon effects and durability + weapon_broke = False + weapon_effects = {} + equipment = await db.get_all_equipment(attacker_id) + 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: + weapon_effects = weapon_def.weapon_effects if hasattr(weapon_def, 'weapon_effects') else {} + + # Apply defense / armor + armor_absorbed = 0 + broken_armor = [] + + if is_pvp: + # PvP: use equipment-based armor reduction + durability + armor_absorbed, broken_armor = await reduce_armor_func(target['id'], damage) + actual_damage = max(1, damage - armor_absorbed) + else: + # PvE: use NPC's flat defense value + npc_defense = target.get('defense', 0) + armor_absorbed = npc_defense if npc_defense > 0 else 0 + actual_damage = max(1, damage - npc_defense) + + 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=armor_absorbed + )) + + for broken in broken_armor: + messages.append(create_combat_message( + "item_broken", origin="enemy", + item_name=broken['name'], emoji=broken['emoji'] + )) + + # Weapon bleeding effects (PvE and PvP) + bleed_damage = 0 + if weapon_effects and 'bleeding' in weapon_effects: + bleeding = weapon_effects['bleeding'] + if random.random() < bleeding.get('chance', 0): + bleed_damage = bleeding.get('damage', 0) + if is_pvp: + # Apply as a status effect on the target player + await db.add_effect( + player_id=target['id'], + effect_name="Bleeding", + effect_icon="🩸", + effect_type="damage", + damage_per_tick=bleed_damage, + ticks_remaining=3, + persist_after_combat=False, + source="weapon_bleeding" + ) + messages.append(create_combat_message( + "effect_bleeding", origin="player", damage=bleed_damage + )) + + # Apply damage to target + new_target_hp = max(0, target['hp'] - actual_damage) + if not is_pvp and bleed_damage > 0: + new_target_hp = max(0, new_target_hp - bleed_damage) + + # Weapon durability + if 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: + weapon_broke = True + messages.append(create_combat_message( + "weapon_broke", origin="player", + item_name=weapon_def.name if weapon_def else "weapon" + )) + await db.unequip_item(attacker_id, 'weapon') + + return { + 'messages': messages, + 'damage_dealt': actual_damage, + 'target_hp': new_target_hp, + 'target_defeated': new_target_hp <= 0, + 'weapon_broke': weapon_broke, + } + + +# ============================================================================ +# SKILL ACTION +# ============================================================================ + +async def execute_skill( + player_id: int, + player: dict, + player_stats: dict, + target: dict, + skill_id: str, + combat_state: dict, + is_pvp: bool, + items_manager: ItemsManager, + reduce_armor_func, + redis_manager=None, +) -> Dict[str, Any]: + """ + Execute a skill action. Validates requirements, deducts stamina, applies effects. + + combat_state: For PvE, the combat dict. For PvP, can be minimal. + + Returns: { + messages, damage_dealt, target_hp, target_defeated, + player_hp, player_stamina, error (str or None) + } + """ + from ..services.skills import skills_manager + + skill = skills_manager.get_skill(skill_id) + if not skill: + return {'error': 'Skill not found', 'status_code': 404} + + # Check unlocked + stat_val = player.get(skill.stat_requirement, 0) + if stat_val < skill.stat_threshold or player['level'] < skill.level_requirement: + return {'error': 'Skill not unlocked', 'status_code': 400} + + # 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: + return {'error': f"Skill on cooldown ({eff['ticks_remaining']} turns)", 'status_code': 400} + + # Check stamina + if player['stamina'] < skill.stamina_cost: + return {'error': 'Not enough stamina', 'status_code': 400} + + # Deduct stamina + new_stamina = player['stamina'] - skill.stamina_cost + await db.update_player_stamina(player_id, new_stamina) + + # Add cooldown + 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 for damage skills + equipment = await db.get_all_equipment(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'] + + # Get buff modifiers + buff_mods = await get_active_buff_modifiers(player_id) + + messages = [] + effects = skill.effects + target_hp = target['hp'] + damage_dealt = 0 + player_hp = player['hp'] + + # ── Damage skills ── + if 'damage_multiplier' in effects: + # Use derived attack_power for base (includes str, level, weapon, perks) + base_damage = player_stats.get('attack_power', 5) + variance = random.randint(-2, 2) + raw_damage = max(1, base_damage + variance) + + # Apply berserker rage bonus + if buff_mods['damage_bonus'] > 0: + raw_damage = int(raw_damage * (1 + buff_mods['damage_bonus'])) + + multiplier = effects['damage_multiplier'] + + # Execute check + if 'execute_threshold' in effects: + hp_pct = target['hp'] / target['max_hp'] if target['max_hp'] > 0 else 1 + if hp_pct <= effects['execute_threshold']: + multiplier = effects.get('execute_multiplier', multiplier) + + # Exploit weakness (requires analyzed — PvE only via npc_status_effects) + if effects.get('requires_analyzed') and not is_pvp: + analyzed = combat_state.get('npc_status_effects', '') or '' + if 'analyzed' not in analyzed: + multiplier = 1.0 + + damage = max(1, int(raw_damage * multiplier)) + + # Guaranteed crit + if effects.get('guaranteed_crit'): + damage = int(damage * player_stats.get('crit_damage', 1.5)) + + # Multi-hit + num_hits = effects.get('hits', 1) + total_damage = 0 + total_armor_absorbed = 0 + + for hit in range(num_hits): + hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier)) + + if is_pvp: + # PvP: armor from equipment + absorbed, broken_armor = await reduce_armor_func(target['id'], hit_dmg) + total_armor_absorbed += absorbed + for broken in broken_armor: + messages.append(create_combat_message( + "item_broken", origin="enemy", + item_name=broken['name'], emoji=broken['emoji'] + )) + actual_hit = max(1, hit_dmg - absorbed) + else: + # PvE: NPC flat defense + npc_defense = target.get('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_armor_absorbed += npc_defense + + total_damage += actual_hit + target_hp = max(0, target_hp - actual_hit) + + damage_dealt = total_damage + + 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.get('max_hp', player_stats.get('max_hp', 100)), 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: + if is_pvp: + # PvP: add as player effect + await db.add_effect( + player_id=target['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}" + ) + else: + # PvE: add to npc_status_effects string + poison_str = f"poison:{effects['poison_damage']}:{effects['poison_duration']}" + existing = combat_state.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']: + if is_pvp: + await db.add_effect( + player_id=target['id'], + effect_name="Stunned", + effect_icon="💫", + effect_type="debuff", + ticks_remaining=1, + persist_after_combat=False, + source="skill_stun" + ) + else: + # PvE: write stun to npc_status_effects string + stun_str = "stun:1" + existing = combat_state.get('npc_status_effects', '') or '' + # Refresh combat state in case poison just updated it + fresh_combat = await db.get_active_combat(player_id) + if fresh_combat: + existing = fresh_combat.get('npc_status_effects', '') or '' + if existing: + existing += '|' + stun_str + else: + existing = stun_str + await db.update_combat(player_id, {'npc_status_effects': existing}) + + 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: + max_hp = player.get('max_hp', player_stats.get('max_hp', 100)) + heal_amount = int(max_hp * effects['heal_percent']) + new_hp = min(max_hp, player_hp + heal_amount) + 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( + "skill_heal", origin="player", + heal=actual_heal, skill_name=skill.name, skill_icon=skill.icon + )) + + # ── Stamina restore ── + if 'stamina_restore_percent' in effects: + max_stam = player.get('max_stamina', player_stats.get('max_stamina', 100)) + restore = int(max_stam * effects['stamina_restore_percent']) + new_stam = min(max_stam, new_stamina + restore) + actual = new_stam - new_stamina + if actual > 0: + await db.update_player_stamina(player_id, new_stam) + new_stamina = new_stam + messages.append(create_combat_message( + "skill_effect", origin="player", message=f"⚡ +{actual} Stamina" + )) + + # ── Buff skills (fortify, berserker, evade, iron_skin, foresight) ── + if 'buff' in effects: + buff_name = 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, + 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 (PvE only) ── + if effects.get('mark_analyzed') and not is_pvp: + existing = combat_state.get('npc_status_effects', '') or '' + # Refresh in case previous effects updated it + fresh_combat = await db.get_active_combat(player_id) + if fresh_combat: + existing = fresh_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((target['hp'] / target['max_hp']) * 100) if target['max_hp'] > 0 else 0 + intent = combat_state.get('npc_intent', 'attack') + messages.append(create_combat_message( + "skill_analyze", origin="player", + skill_icon=skill.icon, + npc_name=target['name'], + npc_hp_pct=npc_hp_pct, + npc_intent=intent + )) + + return { + 'messages': messages, + 'damage_dealt': damage_dealt, + 'target_hp': target_hp, + 'target_defeated': target_hp <= 0, + 'player_hp': player_hp, + 'player_stamina': new_stamina, + 'error': None, + } + + +# ============================================================================ +# USE ITEM ACTION +# ============================================================================ + +async def execute_use_item( + player_id: int, + player: dict, + player_stats: dict, + item_id: str, + combat_state: dict, + target: dict, + is_pvp: bool, + items_manager: ItemsManager, + locale: str = 'en', +) -> Dict[str, Any]: + """ + Use a combat item. Returns results without side effects on combat flow. + + Returns: { + messages, effects_applied, target_hp, target_defeated, + player_hp, combat_over, error (str or None) + } + """ + # Get from inventory + player_inventory = await db.get_inventory(player_id) + inv_item = None + for item in player_inventory: + if item['item_id'] == item_id: + inv_item = item + break + + if not inv_item: + return {'error': 'Item not found in inventory', 'status_code': 400} + + item_def = items_manager.get_item(item_id) + if not item_def: + return {'error': 'Unknown item', 'status_code': 400} + if not item_def.combat_usable: + return {'error': 'This item cannot be used in combat', 'status_code': 400} + + messages = [] + effects_applied = [] + item_name = get_locale_string(item_def.name, locale) + player_hp = player['hp'] + target_hp = target['hp'] if target else 0 + target_defeated = False + old_hp = player_hp + old_stamina = player.get('stamina', 0) + + # 1. Status effects (e.g. Regeneration from Bandage) + if item_def.effects.get('status_effect'): + # Check status immunity + buff_mods = await get_active_buff_modifiers(player_id) + 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, + source=f"item:{item_def.id}" + ) + effects_applied.append(f"Applied {status_data['name']}") + + # 2. Cure status effects + if item_def.effects.get('cures'): + for cure_effect in item_def.effects['cures']: + if await db.remove_effect(player_id, cure_effect): + effects_applied.append(f"Cured {cure_effect}") + + # 3. HP restore + if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0: + item_effectiveness = player_stats.get('item_effectiveness', 1.0) + restore_amount = int(item_def.effects['hp_restore'] * item_effectiveness) + max_hp = player.get('max_hp', player_stats.get('max_hp', 100)) + new_hp = min(max_hp, player_hp + restore_amount) + actual_heal = new_hp - player_hp + if actual_heal > 0: + await db.update_player_hp(player_id, new_hp) + player_hp = new_hp + effects_applied.append(f"+{actual_heal} HP") + messages.append(create_combat_message("item_heal", origin="player", heal=actual_heal)) + + # 4. Stamina restore + if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0: + item_effectiveness = player_stats.get('item_effectiveness', 1.0) + restore_amount = int(item_def.effects['stamina_restore'] * item_effectiveness) + max_stam = player.get('max_stamina', player_stats.get('max_stamina', 100)) + new_stamina = min(max_stam, player.get('stamina', 0) + restore_amount) + actual_restore = new_stamina - player.get('stamina', 0) + if actual_restore > 0: + await db.update_player_stamina(player_id, new_stamina) + effects_applied.append(f"+{actual_restore} Stamina") + messages.append(create_combat_message( + "item_restore", origin="player", stat="stamina", amount=actual_restore + )) + + # 5. Combat effects (throwables — PvE only for now) + combat_effects = getattr(item_def, 'combat_effects', None) or {} + if combat_effects.get('damage_min') and combat_effects.get('damage_max') and not is_pvp: + throwable_damage = random.randint(combat_effects['damage_min'], combat_effects['damage_max']) + target_hp = max(0, target_hp - throwable_damage) + effects_applied.append(f"{throwable_damage} damage") + messages.append(create_combat_message( + "item_damage", origin="player", + damage=throwable_damage, item_name=item_name + )) + target_defeated = target_hp <= 0 + + # 6. Status effect on target (burn from molotov etc.) — PvE only + status_effect = combat_effects.get('status') if not is_pvp else None + if status_effect and not target_defeated: + 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, item_id, 1) + await db.update_player_statistics(player_id, items_used=1, increment=True) + + # Item used message + effects_str = f" ({', '.join(effects_applied)})" if effects_applied else "" + hp_restored_val = player_hp - old_hp if player_hp > old_hp else 0 + stamina_restored_val = 0 # Simplified + + 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 + )) + + return { + 'messages': messages, + 'effects_applied': effects_applied, + 'target_hp': target_hp, + 'target_defeated': target_defeated, + 'player_hp': player_hp, + 'error': None, + } + + +# ============================================================================ +# FLEE ACTION +# ============================================================================ + +async def execute_flee_pve( + player_id: int, + player: dict, + player_stats: dict, + combat: dict, + npc_def, + reduce_armor_func, + locale: str = 'en', +) -> Dict[str, Any]: + """ + Attempt to flee from PvE combat. + + Returns: { + messages, success, combat_over, player_defeated, + player_hp, corpse_data (if died) + } + """ + messages = [] + flee_chance = player_stats.get('flee_chance_base', 0.5) + + if random.random() < flee_chance: + # Success + messages.append(create_combat_message( + "flee_success", origin="player", + message=get_game_message('flee_success_text', locale, name=player['name']) + )) + await db.update_player_statistics(player_id, successful_flees=1, increment=True) + + # Respawn wandering enemy + if combat.get('from_wandering_enemy'): + 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) + + return { + 'messages': messages, 'success': True, + 'combat_over': True, 'player_defeated': False, + 'player_hp': player['hp'], 'corpse_data': None + } + else: + # Failed — NPC gets a free attack + npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) + + # Apply player's defensive buffs to flee penalty + buff_mods = await get_active_buff_modifiers(player_id) + if buff_mods['guaranteed_dodge'] or buff_mods['enemy_miss']: + npc_damage = 0 + messages.append(create_combat_message("combat_dodge", origin="player")) + else: + if buff_mods['damage_reduction'] > 0: + npc_damage = max(1, int(npc_damage * (1 - buff_mods['damage_reduction']))) + + 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: + # Died during flee attempt + messages.append(create_combat_message( + "player_defeated", origin="neutral", npc_name=npc_def.name + )) + 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 + corpse_data = await _create_player_corpse(player_id, player, combat['location_id']) + + # Respawn enemy + if combat.get('from_wandering_enemy'): + 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) + + return { + 'messages': messages, 'success': False, + 'combat_over': True, 'player_defeated': True, + 'player_hp': 0, 'corpse_data': corpse_data + } + else: + 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()}) + + return { + 'messages': messages, 'success': False, + 'combat_over': False, 'player_defeated': False, + 'player_hp': new_player_hp, 'corpse_data': None + } + + +async def execute_flee_pvp( + player_id: int, + player: dict, + player_stats: dict, + locale: str = 'en', +) -> Dict[str, Any]: + """ + Attempt to flee from PvP combat. No penalty damage, just chance-based. + + Returns: { messages, success } + """ + messages = [] + flee_chance = player_stats.get('flee_chance_base', 0.5) + + if random.random() < flee_chance: + text = get_game_message('flee_success_text', locale, name=player['name']) + messages.append(create_combat_message("flee_success", origin="player", message=text)) + return {'messages': messages, 'success': True, 'last_action': text} + else: + text = get_game_message('flee_fail_text', locale, name=player['name']) + messages.append(create_combat_message( + "flee_fail", origin="player", reason="chance", message=text + )) + return {'messages': messages, 'success': False, 'last_action': text} + + +# ============================================================================ +# VICTORY / DEFEAT HELPERS +# ============================================================================ + +async def handle_victory_pve( + player: dict, + combat: dict, + npc_def, + items_manager: ItemsManager, + quests_data: dict, + redis_manager, + locale: str = 'en', +) -> Dict[str, Any]: + """ + Handle all post-victory PvE logic: XP, level up, corpse, quest progress. + + Returns: { messages, xp_gained, level_up_result, quest_updates } + """ + from .. import game_logic + + messages = [] + quest_updates = [] + + # 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 + await db.update_player_statistics(player['id'], enemies_killed=1, increment=True) + + # Level up check + 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 + 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.dumps(corpse_loot_dicts) + ) + + # Quest progress + 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() + progress_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 + progress_changed = True + + if progress_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' and 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}" + )) + + updated_q_data = dict(q_record) + updated_q_data['start_at'] = q_record['started_at'] + updated_q_data.update(q_def) + quest_updates.append(updated_q_data) + except Exception as e: + logger.error(f"Failed to update quest progress: {e}") + + # End combat + await db.remove_non_persistent_effects(player['id']) + await db.end_combat(player['id']) + + # Redis cache update + if redis_manager: + await redis_manager.delete_combat_state(player['id']) + 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']) + + return { + 'messages': messages, + 'xp_gained': xp_gained, + 'new_xp': new_xp, + 'level_up_result': level_up_result, + 'quest_updates': quest_updates, + } + + +async def handle_victory_pvp( + winner: dict, + loser: dict, + damage_dealt: int, + items_manager: ItemsManager, + locale: str = 'en', +) -> Dict[str, Any]: + """ + Handle PvP victory: corpse creation, stats, inventory clear. + + Returns: { messages, corpse_data } + """ + messages = [] + + messages.append(create_combat_message("victory", origin="neutral", npc_name=loser['name'])) + await db.update_player(loser['id'], hp=0, is_dead=True) + + # Create corpse + corpse_data = await _create_player_corpse(loser['id'], loser, loser['location_id'], items_manager) + + # Stats + await db.update_player_statistics(loser['id'], + pvp_deaths=1, pvp_combats_lost=1, + pvp_damage_taken=damage_dealt, pvp_attacks_received=1, + increment=True + ) + await db.update_player_statistics(winner['id'], + players_killed=1, pvp_combats_won=1, + pvp_damage_dealt=damage_dealt, pvp_attacks_landed=1, + increment=True + ) + + return { + 'messages': messages, + 'corpse_data': corpse_data, + } + + +async def handle_defeat_pve( + player_id: int, + player: dict, + combat: dict, + npc_def, + items_manager: ItemsManager, + locale: str = 'en', +) -> Dict[str, Any]: + """ + Handle PvE player death: corpse, stats, respawn enemy, end combat. + + Returns: { messages, corpse_data } + """ + messages = [] + + messages.append(create_combat_message( + "player_defeated", origin="neutral", npc_name=npc_def.name + )) + await db.update_player(player_id, hp=0, is_dead=True) + await db.update_player_statistics(player_id, deaths=1, increment=True) + + # Create corpse + corpse_data = await _create_player_corpse(player_id, player, combat.get('location_id', player.get('location_id', '')), items_manager) + + # Respawn enemy + if combat.get('from_wandering_enemy'): + 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) + + return { + 'messages': messages, + 'corpse_data': corpse_data, + } + + +# ============================================================================ +# NPC TURN (wraps game_logic.npc_attack with buff checking) +# ============================================================================ + +async def execute_npc_turn( + player_id: int, + combat: dict, + npc_def, + reduce_armor_func, + redis_manager=None, +) -> Tuple[List[dict], bool]: + """ + Execute the NPC's turn with buff-aware damage reduction. + Wraps game_logic.npc_attack but also checks for player buffs + that alter incoming damage (fortify, berserker's damage taken increase). + + Returns: (messages, player_defeated) + """ + from ..services.stats import calculate_derived_stats + stats = await calculate_derived_stats(player_id, redis_manager) + + # Check buff modifiers to pass along to npc_attack + buff_mods = await get_active_buff_modifiers(player_id) + + # Inject buff-based modifiers into player_stats so npc_attack can use them + stats['buff_damage_reduction'] = buff_mods['damage_reduction'] + stats['buff_damage_taken_increase'] = buff_mods['damage_taken_increase'] + stats['buff_guaranteed_dodge'] = buff_mods['guaranteed_dodge'] + stats['buff_enemy_miss'] = buff_mods['enemy_miss'] + + from ..game_logic import npc_attack + messages, player_defeated = await npc_attack( + player_id, combat, npc_def, reduce_armor_func, player_stats=stats + ) + + return messages, player_defeated + + +# ============================================================================ +# INTERNAL HELPERS +# ============================================================================ + +async def _create_player_corpse( + player_id: int, + player: dict, + location_id: str, + items_manager: ItemsManager = None, +) -> Optional[Dict[str, Any]]: + """ + Create a corpse for a dead player with their inventory. + Returns corpse_data dict or None if no items. + """ + import time as time_module + + inventory = await db.get_inventory(player_id) + if not inventory: + return None + + inventory_items = [] + for inv_item in inventory: + item_name = inv_item['item_id'] + item_emoji = '📦' + if items_manager: + item_def = items_manager.get_item(inv_item['item_id']) + if item_def: + item_name = item_def.name if hasattr(item_def, 'name') else inv_item['item_id'] + item_emoji = item_def.emoji if hasattr(item_def, 'emoji') else '📦' + + inventory_items.append({ + 'item_id': inv_item['item_id'], + 'name': item_name, + 'emoji': item_emoji, + 'quantity': inv_item['quantity'], + 'durability': inv_item.get('durability'), + 'max_durability': inv_item.get('max_durability'), + 'tier': inv_item.get('tier') + }) + + if not inventory_items: + return None + + corpse_id = await db.create_player_corpse( + player_name=player['name'], + location_id=location_id, + items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) + ) + await db.clear_inventory(player_id) + + return { + "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() + } diff --git a/api/services/models.py b/api/services/models.py index 9564876..a454682 100644 --- a/api/services/models.py +++ b/api/services/models.py @@ -89,12 +89,13 @@ class PvPCombatInitiateRequest(BaseModel): class PvPAcknowledgeRequest(BaseModel): - pass # No body needed + combat_id: int class PvPCombatActionRequest(BaseModel): - action: str # 'attack', 'defend', 'flee', 'use_item' + action: str # 'attack', 'skill', 'flee', 'use_item' item_id: Optional[str] = None # For use_item action + skill_id: Optional[str] = None # For skill action # ============================================================================