Files
echoes-of-the-ash/api/services/combat_engine.py

1325 lines
48 KiB
Python

"""
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:
is_defending = False
target_effects = await db.get_player_effects(target['id'])
defending_effect = next((e for e in target_effects if e['effect_name'] == 'defending'), None)
if defending_effect:
is_defending = True
reduction = defending_effect.get('value', 50) / 100
damage = max(1, int(damage * (1 - reduction)))
messages.append(create_combat_message("damage_reduced", origin="enemy", reduction=int(reduction * 100)))
await db.remove_effect(target['id'], 'defending')
# PvP: use equipment-based armor reduction + durability
armor_absorbed, broken_armor = await reduce_armor_func(target['id'], damage, is_defending)
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,
}
async def execute_defend(
player_id: int,
player: dict,
player_stats: dict,
is_pvp: bool,
locale: str = 'en'
) -> Dict[str, Any]:
"""
Execute a defend action.
Reduces incoming damage by 50% for the next turn, but increases durability loss.
Returns: {
messages: list
}
"""
from .. import database as db
from .helpers import create_combat_message, get_game_message
messages = []
# 5% Stamina restore
stamina_restore = max(5, int(player_stats.get('max_stamina', 100) * 0.05))
new_stamina = min(player_stats.get('max_stamina', 100), player.get('stamina', 100) + stamina_restore)
await db.update_player(player_id, stamina=new_stamina)
# Add defending effect
await db.add_effect(
player_id=player_id,
effect_name="defending",
effect_icon="🛡️",
effect_type="buff",
ticks_remaining=1,
persist_after_combat=False,
source="combat_defend",
value=50 # 50% reduction
)
messages.append(create_combat_message(
"player_defend",
origin="player"
))
return {
'messages': messages,
'stamina_restored': stamina_restore,
}
# ============================================================================
# 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,
locale: str = 'en'
) -> 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))
is_defending = False
if is_pvp:
target_effects = await db.get_player_effects(target['id'])
defending_effect = next((e for e in target_effects if e['effect_name'] == 'defending'), None)
if defending_effect:
is_defending = True
reduction = defending_effect.get('value', 50) / 100
damage = max(1, int(damage * (1 - reduction)))
messages.append(create_combat_message("damage_reduced", origin="enemy", reduction=int(reduction * 100)))
await db.remove_effect(target['id'], 'defending')
# 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, is_defending)
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="🩸"
))
from .helpers import calculate_dynamic_status_damage
# Poison DoT
poison_dmg = calculate_dynamic_status_damage(effects, 'poison', target)
if poison_dmg is not None:
poison_dur = effects.get('poison_duration', 3)
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=poison_dmg,
ticks_remaining=poison_dur,
persist_after_combat=True,
source=f"skill_poison:{skill.id}"
)
else:
# PvE: add to npc_status_effects string
poison_str = f"poison:{poison_dmg}:{poison_dur}"
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! ({poison_dmg} dmg/turn)"
))
# Burn DoT
burn_dmg = calculate_dynamic_status_damage(effects, 'burn', target)
if burn_dmg is not None:
burn_dur = effects.get('burn_duration', 3)
if is_pvp:
# PvP: add as player effect
await db.add_effect(
player_id=target['id'],
effect_name="Burning",
effect_icon="🔥",
effect_type="damage",
damage_per_tick=burn_dmg,
ticks_remaining=burn_dur,
persist_after_combat=True,
source=f"skill_burn:{skill.id}"
)
else:
# PvE: add to npc_status_effects string
burn_str = f"burning:{burn_dmg}:{burn_dur}"
existing = combat_state.get('npc_status_effects', '') or ''
if existing:
existing += '|' + burn_str
else:
existing = burn_str
await db.update_combat(player_id, {'npc_status_effects': existing})
messages.append(create_combat_message(
"skill_effect", origin="player",
message=f"🔥 Burning! ({burn_dmg} 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=get_game_message('stunned_status', locale)
))
# 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:
dmg = status_effect.get('damage_per_tick', 0)
if 'damage_percent' in status_effect:
max_hp = target.get('npc_max_hp', target.get('max_hp', 100))
base_dmg = max_hp * status_effect['damage_percent']
dmg = random.randint(max(1, int(base_dmg * 0.8)), max(1, int(base_dmg * 1.2)))
npc_status = f"{status_effect['name']}:{dmg}:{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,
locale: str = 'en'
) -> 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, locale=locale
)
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()
}