Pre-menu-integration snapshot: combat, crafting, status effects, gamedata updates
This commit is contained in:
@@ -599,29 +599,85 @@ def generate_npc_intent(npc_def, combat_state: dict) -> dict:
|
||||
Generate the NEXT intent for an NPC.
|
||||
Returns a dict with intent type and details.
|
||||
"""
|
||||
# Default intent is attack
|
||||
intent = {"type": "attack", "value": 0}
|
||||
import random
|
||||
from api.services.skills import skills_manager
|
||||
|
||||
# Logic could be more complex based on NPC type, HP, etc.
|
||||
roll = random.random()
|
||||
npc_hp_pct = combat_state['npc_hp'] / combat_state['npc_max_hp'] if combat_state['npc_max_hp'] > 0 else 0
|
||||
skills = getattr(npc_def, 'skills', [])
|
||||
|
||||
# 20% chance to defend if HP < 50%
|
||||
if (combat_state['npc_hp'] / combat_state['npc_max_hp'] < 0.5) and roll < 0.2:
|
||||
intent = {"type": "defend", "value": 0}
|
||||
# 15% chance for special attack (if defined, otherwise strong attack)
|
||||
elif roll < 0.35:
|
||||
intent = {"type": "special", "value": 0}
|
||||
else:
|
||||
intent = {"type": "attack", "value": 0}
|
||||
active_effects = combat_state.get('npc_status_effects', '')
|
||||
|
||||
cooldowns = {}
|
||||
if active_effects:
|
||||
for eff in active_effects.split('|'):
|
||||
if eff.startswith('cd_'):
|
||||
parts = eff.split(':')
|
||||
if len(parts) >= 2:
|
||||
cooldowns[parts[0][3:]] = int(parts[1])
|
||||
|
||||
available_skills = []
|
||||
has_heal = None
|
||||
has_buff = None
|
||||
damage_skills = []
|
||||
|
||||
for skill_id in skills:
|
||||
if cooldowns.get(skill_id, 0) > 0:
|
||||
continue
|
||||
skill = skills_manager.get_skill(skill_id)
|
||||
if not skill: continue
|
||||
available_skills.append(skill)
|
||||
|
||||
return intent
|
||||
if 'heal_percent' in skill.effects:
|
||||
has_heal = skill
|
||||
elif 'buff' in skill.effects:
|
||||
has_buff = skill
|
||||
else:
|
||||
damage_skills.append(skill)
|
||||
|
||||
# 1. Survival First
|
||||
if has_heal and npc_hp_pct < 0.3:
|
||||
if random.random() < 0.8:
|
||||
return {"type": "skill", "value": has_heal.id}
|
||||
|
||||
# 2. Buffs
|
||||
if has_buff:
|
||||
buff_name = has_buff.effects['buff']
|
||||
is_buff_active = False
|
||||
if active_effects:
|
||||
for eff in active_effects.split('|'):
|
||||
if eff.startswith(buff_name + ':'):
|
||||
is_buff_active = True
|
||||
break
|
||||
if not is_buff_active and random.random() < 0.6:
|
||||
return {"type": "skill", "value": has_buff.id}
|
||||
|
||||
# 3. Telegraphed Attack Check (15% chance if health > 30%)
|
||||
if npc_hp_pct > 0.3 and random.random() < 0.15:
|
||||
return {"type": "charge", "value": "charging_attack"}
|
||||
|
||||
# 4. Damage Skills
|
||||
if damage_skills and random.random() < 0.4:
|
||||
chosen = random.choice(damage_skills)
|
||||
return {"type": "skill", "value": chosen.id}
|
||||
|
||||
# Default to attack or defend (legacy logic)
|
||||
roll = random.random()
|
||||
if npc_hp_pct < 0.5 and roll < 0.1:
|
||||
return {"type": "defend", "value": 0}
|
||||
|
||||
return {"type": "attack", "value": 0}
|
||||
|
||||
|
||||
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, player_stats: dict = None) -> Tuple[List[dict], bool]:
|
||||
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, player_stats: dict = None, locale: str = 'en') -> Tuple[List[dict], bool]:
|
||||
"""
|
||||
Execute NPC turn based on PREVIOUS intent, then generate NEXT intent.
|
||||
Returns: (messages_list, player_defeated)
|
||||
"""
|
||||
import random
|
||||
import time
|
||||
from api import database as db
|
||||
from api.services.helpers import create_combat_message, get_game_message, get_locale_string
|
||||
from api.services.skills import skills_manager
|
||||
player = await db.get_player_by_id(player_id)
|
||||
if not player:
|
||||
return [], True
|
||||
@@ -635,13 +691,10 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p
|
||||
is_stunned = False
|
||||
|
||||
if npc_status_str:
|
||||
# Parse status: "bleeding:5:3" (name:dmg:ticks) or "stun:1"
|
||||
# Handling multiple effects separated by |
|
||||
effects_list = npc_status_str.split('|')
|
||||
active_effects = []
|
||||
npc_damage_taken = 0
|
||||
npc_healing_received = 0
|
||||
is_stunned = False
|
||||
|
||||
for effect_str in effects_list:
|
||||
if not effect_str: continue
|
||||
@@ -656,18 +709,24 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p
|
||||
messages.append(create_combat_message(
|
||||
"skill_effect",
|
||||
origin="enemy",
|
||||
message=f"💫 {npc_def.name} is stunned and cannot act!"
|
||||
message=get_game_message('npc_stunned_cannot_act', locale, npc_name=get_locale_string(npc_def.name, locale))
|
||||
))
|
||||
ticks -= 1
|
||||
if ticks > 0:
|
||||
active_effects.append(f"stun:{ticks}")
|
||||
continue
|
||||
|
||||
if name.startswith('cd_') and len(parts) >= 3:
|
||||
ticks = int(parts[2])
|
||||
ticks -= 1
|
||||
if ticks > 0:
|
||||
active_effects.append(f"{name}:{parts[1]}:{ticks}")
|
||||
continue
|
||||
|
||||
if len(parts) >= 3:
|
||||
dmg = int(parts[1])
|
||||
ticks = int(parts[2])
|
||||
|
||||
# Apply effect
|
||||
if ticks > 0:
|
||||
if dmg > 0:
|
||||
npc_damage_taken += dmg
|
||||
@@ -682,272 +741,210 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p
|
||||
heal = abs(dmg)
|
||||
npc_healing_received += heal
|
||||
messages.append(create_combat_message(
|
||||
"effect_heal", # Check if this message type exists or fallback
|
||||
"effect_heal",
|
||||
origin="enemy",
|
||||
heal=heal,
|
||||
effect_name=name,
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
elif name in ["berserker_rage", "fortify", "analyzed"]:
|
||||
pass
|
||||
|
||||
# Decrement tick
|
||||
ticks -= 1
|
||||
if ticks > 0:
|
||||
active_effects.append(f"{name}:{dmg}:{ticks}")
|
||||
except Exception as e:
|
||||
print(f"Error parsing NPC status: {e}")
|
||||
|
||||
# Update NPC active effects
|
||||
new_status_str = "|".join(active_effects)
|
||||
if new_status_str != npc_status_str:
|
||||
await db.update_combat(player_id, {'npc_status_effects': new_status_str})
|
||||
|
||||
# Apply Total Damage/Healing
|
||||
if npc_damage_taken > 0:
|
||||
npc_hp = max(0, npc_hp - npc_damage_taken)
|
||||
|
||||
if npc_healing_received > 0:
|
||||
npc_hp = min(npc_max_hp, npc_hp + npc_healing_received)
|
||||
|
||||
# Update NPC HP in DB
|
||||
await db.update_combat(player_id, {'npc_hp': npc_hp})
|
||||
|
||||
# Check if NPC died from effects
|
||||
if npc_hp <= 0:
|
||||
messages.append(create_combat_message(
|
||||
"victory",
|
||||
origin="neutral",
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
# Award XP/Loot logic handled in combat route mostly, but we need to signal it.
|
||||
# Returning true for player_defeated is definitely WRONG here if NPC died.
|
||||
# The router usually handles "victory" check after action.
|
||||
# But here this is triggered during NPC turn (which happens after Player turn).
|
||||
# If NPC dies on its OWN turn, we need to handle it.
|
||||
# However, typically NPC dies on Player turn.
|
||||
# If NPC dies from bleeding on its turn, the player wins.
|
||||
# We need to signal this back to router.
|
||||
# But the current return signature is (messages, player_defeated).
|
||||
# We might need to handle the win logic here or update signature.
|
||||
# For now, let's update HP and let the flow continue.
|
||||
# Wait, if NPC is dead, it shouldn't attack!
|
||||
# returning here prevents NPC from attacking if it died from status effects
|
||||
messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name))
|
||||
return messages, False
|
||||
|
||||
# Parse current intent (stored in DB as string or JSON, assuming simple string for now or we parse it)
|
||||
current_intent_str = combat.get('npc_intent', 'attack')
|
||||
# Handle legacy/null
|
||||
if not current_intent_str:
|
||||
current_intent_str = 'attack'
|
||||
|
||||
intent_type = current_intent_str
|
||||
intent_parts = current_intent_str.split(':')
|
||||
intent_type = intent_parts[0]
|
||||
intent_value = intent_parts[1] if len(intent_parts) > 1 else None
|
||||
|
||||
actual_damage = 0
|
||||
|
||||
# EXECUTE INTENT
|
||||
if npc_hp > 0 and not is_stunned: # Only attack if alive and not stunned
|
||||
new_player_hp = player['hp']
|
||||
|
||||
if npc_hp > 0 and not is_stunned:
|
||||
if intent_type == 'defend':
|
||||
# NPC defends - heals 5% HP
|
||||
heal_amount = int(combat['npc_max_hp'] * 0.05)
|
||||
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
|
||||
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||
messages.append(create_combat_message("enemy_defend", origin="enemy", npc_name=npc_def.name, heal=heal_amount))
|
||||
|
||||
elif intent_type == 'charge':
|
||||
messages.append(create_combat_message(
|
||||
"enemy_defend",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
heal=heal_amount
|
||||
"skill_effect", origin="enemy", message=get_game_message('enemy_charging', locale, enemy=get_locale_string(npc_def.name, locale))
|
||||
))
|
||||
|
||||
elif intent_type == 'special':
|
||||
# Strong attack (1.5x damage)
|
||||
npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5)
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"enemy_special",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
damage=npc_damage,
|
||||
armor_absorbed=armor_absorbed
|
||||
))
|
||||
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
messages.append(create_combat_message(
|
||||
"item_broken",
|
||||
origin="player",
|
||||
item_name=armor['name'],
|
||||
emoji=armor['emoji']
|
||||
))
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
|
||||
else: # Default 'attack'
|
||||
elif intent_type in ('charging_attack', 'special', 'attack', 'skill'):
|
||||
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
|
||||
skill = None
|
||||
is_charging = intent_type == 'charging_attack'
|
||||
|
||||
# Enrage bonus if NPC is below 30% HP
|
||||
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
|
||||
if is_enraged:
|
||||
if intent_type == 'charging_attack':
|
||||
npc_damage = int(npc_damage * 2.5)
|
||||
elif intent_type == 'special':
|
||||
npc_damage = int(npc_damage * 1.5)
|
||||
messages.append(create_combat_message(
|
||||
"enemy_enraged",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
|
||||
# Check if player is defending (reduces damage by value%)
|
||||
player_effects = await db.get_player_effects(player_id)
|
||||
defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None)
|
||||
if defending_effect:
|
||||
reduction = defending_effect.get('value', 50) / 100 # Default 50% reduction
|
||||
npc_damage = int(npc_damage * (1 - reduction))
|
||||
messages.append(create_combat_message(
|
||||
"damage_reduced",
|
||||
origin="player",
|
||||
reduction=int(reduction * 100)
|
||||
))
|
||||
# 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 guaranteed dodge from Evade buff ──
|
||||
dodged = False
|
||||
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
|
||||
elif intent_type == 'skill' and intent_value:
|
||||
skill = skills_manager.get_skill(intent_value)
|
||||
if skill:
|
||||
if skill.cooldown > 0:
|
||||
cd_str = f"cd_{skill.id}:0:{skill.cooldown}"
|
||||
curr_combat = await db.get_active_combat(player_id)
|
||||
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
|
||||
new_status = curr_status + f"|{cd_str}" if curr_status else cd_str
|
||||
await db.update_combat(player_id, {'npc_status_effects': new_status})
|
||||
|
||||
effects = skill.effects
|
||||
if 'heal_percent' in effects:
|
||||
heal_amount = int(combat['npc_max_hp'] * effects['heal_percent'])
|
||||
new_npc_hp = min(combat['npc_max_hp'], npc_hp + heal_amount)
|
||||
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
|
||||
messages.append(create_combat_message("skill_heal", origin="enemy", heal=heal_amount, skill_icon=skill.icon, skill_name=get_locale_string(skill.name, locale), npc_name=npc_def.name))
|
||||
npc_damage = 0
|
||||
|
||||
if 'buff' in effects:
|
||||
buff_str = f"{effects['buff']}:0:{effects['buff_duration']}"
|
||||
curr_combat = await db.get_active_combat(player_id)
|
||||
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
|
||||
new_status = curr_status + f"|{buff_str}" if curr_status else buff_str
|
||||
await db.update_combat(player_id, {'npc_status_effects': new_status})
|
||||
messages.append(create_combat_message("skill_buff", origin="enemy", skill_name=get_locale_string(skill.name, locale), skill_icon=skill.icon, duration=effects['buff_duration'], npc_name=npc_def.name))
|
||||
if 'damage_multiplier' not in effects and 'poison_damage' not in effects:
|
||||
npc_damage = 0
|
||||
|
||||
if 'damage_multiplier' in effects:
|
||||
npc_damage = max(1, int(npc_damage * effects['damage_multiplier']))
|
||||
|
||||
from api.services.helpers import calculate_dynamic_status_damage
|
||||
poison_dmg = calculate_dynamic_status_damage(effects, 'poison', player)
|
||||
if poison_dmg is not None:
|
||||
await db.add_effect(player_id=player_id, effect_name="Poison", effect_icon="🧪", effect_type="damage", damage_per_tick=poison_dmg, ticks_remaining=effects.get('poison_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}")
|
||||
|
||||
# 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']:
|
||||
burn_dmg = calculate_dynamic_status_damage(effects, 'burn', player)
|
||||
if burn_dmg is not None:
|
||||
await db.add_effect(player_id=player_id, effect_name="Burning", effect_icon="🔥", effect_type="damage", damage_per_tick=burn_dmg, ticks_remaining=effects.get('burn_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}")
|
||||
|
||||
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
|
||||
if is_enraged and npc_damage > 0:
|
||||
npc_damage = int(npc_damage * 1.5)
|
||||
messages.append(create_combat_message("enemy_enraged", origin="enemy", npc_name=npc_def.name))
|
||||
|
||||
curr_combat = await db.get_active_combat(player_id)
|
||||
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
|
||||
if 'berserker_rage' in curr_status and npc_damage > 0:
|
||||
npc_damage = int(npc_damage * 1.5)
|
||||
|
||||
if npc_damage > 0:
|
||||
dodged = False
|
||||
|
||||
is_defending = False
|
||||
player_effects = await db.get_player_effects(player_id)
|
||||
defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None)
|
||||
if defending_effect:
|
||||
is_defending = True
|
||||
reduction = defending_effect.get('value', 50) / 100
|
||||
npc_damage = max(1, int(npc_damage * (1 - reduction)))
|
||||
messages.append(create_combat_message("damage_reduced", origin="player", reduction=int(reduction * 100)))
|
||||
await db.remove_effect(player_id, 'defending')
|
||||
|
||||
buff_dmg_reduction = player_stats.get('buff_damage_reduction', 0.0) if player_stats else 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)))
|
||||
|
||||
buff_dmg_taken_increase = player_stats.get('buff_damage_taken_increase', 0.0) if player_stats else 0.0
|
||||
if buff_dmg_taken_increase > 0:
|
||||
npc_damage = int(npc_damage * (1 + buff_dmg_taken_increase))
|
||||
|
||||
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']
|
||||
|
||||
# Check for block (if shield is equipped)
|
||||
blocked = False
|
||||
if not dodged and player_stats and player_stats.get('has_shield', False):
|
||||
if random.random() < player_stats.get('block_chance', 0):
|
||||
blocked = True
|
||||
messages.append(create_combat_message(
|
||||
"combat_block",
|
||||
origin="player"
|
||||
))
|
||||
npc_damage = max(1, int(npc_damage * 0.2)) # Block mitigates 80% damage
|
||||
messages.append(create_combat_message("combat_dodge", origin="player"))
|
||||
await db.remove_effect(player_id, 'evade')
|
||||
elif player_stats and player_stats.get('buff_enemy_miss', False):
|
||||
dodged = True
|
||||
messages.append(create_combat_message("combat_dodge", origin="player"))
|
||||
elif player_stats and 'dodge_chance' in player_stats and random.random() < player_stats['dodge_chance']:
|
||||
dodged = True
|
||||
messages.append(create_combat_message("combat_dodge", origin="player"))
|
||||
|
||||
if not dodged and player_stats and player_stats.get('has_shield', False) and random.random() < player_stats.get('block_chance', 0):
|
||||
messages.append(create_combat_message("combat_block", origin="player"))
|
||||
npc_damage = max(1, int(npc_damage * 0.2))
|
||||
|
||||
if not dodged:
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage, is_defending)
|
||||
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)))
|
||||
armor_absorbed_visual = npc_damage - actual_damage
|
||||
else:
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
armor_absorbed_visual = armor_absorbed
|
||||
|
||||
if not dodged:
|
||||
# Calculate armor durability loss based on PRE-reduction damage
|
||||
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
|
||||
|
||||
# If player_stats provides a percentage reduction, apply it instead of raw absorption
|
||||
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)))
|
||||
armor_absorbed_visual = npc_damage - actual_damage
|
||||
else:
|
||||
actual_damage = max(1, npc_damage - armor_absorbed)
|
||||
armor_absorbed_visual = armor_absorbed
|
||||
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"enemy_attack",
|
||||
origin="enemy",
|
||||
npc_name=npc_def.name,
|
||||
damage=actual_damage,
|
||||
armor_absorbed=armor_absorbed_visual
|
||||
))
|
||||
|
||||
if broken_armor and not dodged:
|
||||
for armor in broken_armor:
|
||||
messages.append(create_combat_message(
|
||||
"item_broken",
|
||||
origin="player",
|
||||
item_name=armor['name'],
|
||||
emoji=armor['emoji']
|
||||
))
|
||||
new_player_hp = max(0, player['hp'] - actual_damage)
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
if skill and 'damage_multiplier' in skill.effects:
|
||||
messages.append(create_combat_message("skill_attack", origin="enemy", damage=actual_damage, skill_name=get_locale_string(skill.name, locale), skill_icon=skill.icon, hits=1))
|
||||
elif is_charging:
|
||||
messages.append(create_combat_message("enemy_special", origin="enemy", npc_name=npc_def.name, damage=actual_damage, armor_absorbed=armor_absorbed_visual))
|
||||
else:
|
||||
messages.append(create_combat_message("enemy_attack", origin="enemy", npc_name=npc_def.name, damage=actual_damage, armor_absorbed=armor_absorbed_visual))
|
||||
|
||||
if broken_armor:
|
||||
for armor in broken_armor:
|
||||
messages.append(create_combat_message("item_broken", origin="player", item_name=armor['name'], emoji=armor['emoji']))
|
||||
|
||||
await db.update_player(player_id, hp=new_player_hp)
|
||||
|
||||
# GENERATE NEXT INTENT
|
||||
|
||||
# Check if player defeated
|
||||
player_defeated = False
|
||||
if player['hp'] - actual_damage <= 0 and intent_type != 'defend': # Check HP after damage
|
||||
# Re-fetch to be sure or just trust calculation
|
||||
if new_player_hp <= 0:
|
||||
messages.append(create_combat_message(
|
||||
"player_defeated",
|
||||
origin="neutral",
|
||||
npc_name=npc_def.name
|
||||
))
|
||||
player_defeated = True
|
||||
await db.update_player(player_id, hp=0, is_dead=True)
|
||||
await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
|
||||
await db.end_combat(player_id)
|
||||
return messages, player_defeated
|
||||
if new_player_hp <= 0 and intent_type != 'defend' and intent_type != 'charge':
|
||||
messages.append(create_combat_message("player_defeated", origin="neutral", npc_name=npc_def.name))
|
||||
player_defeated = True
|
||||
await db.update_player(player_id, hp=0, is_dead=True)
|
||||
await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
|
||||
await db.end_combat(player_id)
|
||||
return messages, player_defeated
|
||||
|
||||
if not player_defeated:
|
||||
if actual_damage > 0:
|
||||
await db.update_player_statistics(player_id, damage_taken=actual_damage, increment=True)
|
||||
|
||||
# Generate NEXT intent
|
||||
# We need the updated NPC HP for the logic
|
||||
current_npc_hp = combat['npc_hp']
|
||||
if intent_type == 'defend':
|
||||
current_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + int(combat['npc_max_hp'] * 0.05))
|
||||
|
||||
temp_combat_state = combat.copy()
|
||||
temp_combat_state['npc_hp'] = current_npc_hp
|
||||
|
||||
if actual_damage > 0:
|
||||
await db.update_player_statistics(player_id, damage_taken=actual_damage, increment=True)
|
||||
|
||||
current_npc_hp = combat['npc_hp']
|
||||
if intent_type == 'defend':
|
||||
current_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + int(combat['npc_max_hp'] * 0.05))
|
||||
|
||||
temp_combat_state = combat.copy()
|
||||
temp_combat_state['npc_hp'] = current_npc_hp
|
||||
|
||||
if intent_type == 'charge':
|
||||
next_intent_str = 'charging_attack'
|
||||
else:
|
||||
next_intent = generate_npc_intent(npc_def, temp_combat_state)
|
||||
|
||||
# Update combat with new intent and turn
|
||||
await db.update_combat(player_id, {
|
||||
'turn': 'player',
|
||||
'turn_started_at': time.time(),
|
||||
'npc_intent': next_intent['type']
|
||||
})
|
||||
next_intent_str = f"{next_intent['type']}:{next_intent['value']}" if next_intent['type'] == 'skill' else next_intent['type']
|
||||
|
||||
await db.update_combat(player_id, {
|
||||
'turn': 'player',
|
||||
'turn_started_at': time.time(),
|
||||
'npc_intent': next_intent_str
|
||||
})
|
||||
|
||||
return messages, player_defeated
|
||||
|
||||
53
api/give_gear.py
Normal file
53
api/give_gear.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
import asyncpg
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from core.config import settings
|
||||
|
||||
async def main():
|
||||
conn = await asyncpg.connect(settings.DATABASE_URL)
|
||||
|
||||
# Get user
|
||||
user = await conn.fetchrow("SELECT id FROM characters ORDER BY created_at DESC LIMIT 1 OFFSET 0")
|
||||
if not user:
|
||||
print("No user found")
|
||||
return
|
||||
c_id = user['id']
|
||||
print(f"Adding items to character {c_id}")
|
||||
|
||||
# Items: Greatsword, Full Plate, Kite Shield
|
||||
items = [
|
||||
{"item_id": "iron_greatsword", "base_durability": 100},
|
||||
{"item_id": "steel_plate", "base_durability": 200},
|
||||
{"item_id": "iron_kite_shield", "base_durability": 120}
|
||||
]
|
||||
|
||||
for item in items:
|
||||
# Create unique item
|
||||
unique_id = str(uuid.uuid4())
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO unique_items (id, item_id, durability, max_durability)
|
||||
VALUES ($1, $2, $3, $3)
|
||||
""",
|
||||
unique_id, item['item_id'], item['base_durability']
|
||||
)
|
||||
|
||||
# Add to inventory
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO inventory_items (character_id, item_id, quantity, is_equipped, unique_item_id)
|
||||
VALUES ($1, $2, 1, false, $3)
|
||||
""",
|
||||
c_id, item['item_id'], unique_id
|
||||
)
|
||||
print(f"Added {item['item_id']} ({unique_id}) to inventory.")
|
||||
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
17
api/give_gear_final.sql
Normal file
17
api/give_gear_final.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
char_id INTEGER;
|
||||
uid INTEGER;
|
||||
BEGIN
|
||||
SELECT id INTO char_id FROM characters ORDER BY created_at DESC LIMIT 1;
|
||||
IF char_id IS NOT NULL THEN
|
||||
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('iron_greatsword', 100, 100) RETURNING id INTO uid;
|
||||
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'iron_greatsword', 1, false, uid);
|
||||
|
||||
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('steel_plate', 200, 200) RETURNING id INTO uid;
|
||||
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'steel_plate', 1, false, uid);
|
||||
|
||||
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('iron_kite_shield', 120, 120) RETURNING id INTO uid;
|
||||
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'iron_kite_shield', 1, false, uid);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -187,8 +187,8 @@ except Exception as e:
|
||||
game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA)
|
||||
equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
||||
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
||||
admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR)
|
||||
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS)
|
||||
|
||||
@@ -17,13 +17,14 @@ from ..services.constants import PVP_TURN_TIMEOUT
|
||||
|
||||
from ..core.security import get_current_user, security, verify_internal_key
|
||||
from ..services.models import *
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message, get_game_message
|
||||
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message, get_game_message, get_resolved_player_effects
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
from .. import game_logic
|
||||
from ..core.websockets import manager
|
||||
from .equipment import reduce_armor_durability
|
||||
from ..services import combat_engine
|
||||
from ..services.status_effects import status_effects_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -70,6 +71,27 @@ async def get_combat_status(current_user: dict = Depends(get_current_user)):
|
||||
time_elapsed = time.time() - turn_started_at
|
||||
turn_time_remaining = max(0, 300 - time_elapsed)
|
||||
|
||||
# Parse NPC status effects
|
||||
npc_effects_list = []
|
||||
npc_status_str = combat.get('npc_status_effects', '') or ''
|
||||
if npc_status_str:
|
||||
for part in npc_status_str.split('|'):
|
||||
tokens = part.split(':')
|
||||
effect_name = tokens[0] if len(tokens) > 0 else ''
|
||||
if not effect_name:
|
||||
continue
|
||||
ticks = int(tokens[2]) if len(tokens) > 2 else (int(tokens[1]) if len(tokens) > 1 else 0)
|
||||
info = status_effects_manager.get_effect_info(effect_name)
|
||||
npc_effects_list.append({
|
||||
'name': info['name'],
|
||||
'icon': info['icon'],
|
||||
'ticks_remaining': ticks,
|
||||
'description': info['description'],
|
||||
})
|
||||
|
||||
# Get player active buffs/debuffs (exclude cooldowns)
|
||||
player_effects = await get_resolved_player_effects(current_user['id'], in_combat=True)
|
||||
|
||||
return {
|
||||
"in_combat": True,
|
||||
"combat": {
|
||||
@@ -80,8 +102,11 @@ async def get_combat_status(current_user: dict = Depends(get_current_user)):
|
||||
"npc_image": f"{npc_def.image_path}" if npc_def else None,
|
||||
"turn": combat['turn'],
|
||||
"round": combat.get('round', 1),
|
||||
"turn_time_remaining": turn_time_remaining
|
||||
}
|
||||
"turn_time_remaining": turn_time_remaining,
|
||||
"npc_effects": npc_effects_list,
|
||||
"npc_intent": combat.get('npc_intent', 'attack')
|
||||
},
|
||||
"player_effects": player_effects
|
||||
}
|
||||
|
||||
|
||||
@@ -154,8 +179,10 @@ async def initiate_combat(
|
||||
"npc_max_hp": npc_hp,
|
||||
"npc_image": f"{npc_def.image_path}",
|
||||
"turn": "player",
|
||||
"round": 1
|
||||
}
|
||||
"round": 1,
|
||||
"npc_intent": "attack"
|
||||
},
|
||||
"player_effects": await get_resolved_player_effects(current_user['id'], in_combat=True)
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
@@ -185,8 +212,10 @@ async def initiate_combat(
|
||||
"npc_max_hp": npc_hp,
|
||||
"npc_image": f"{npc_def.image_path}",
|
||||
"turn": "player",
|
||||
"round": 1
|
||||
}
|
||||
"round": 1,
|
||||
"npc_intent": "attack"
|
||||
},
|
||||
"player_effects": await get_resolved_player_effects(current_user['id'], in_combat=True)
|
||||
}
|
||||
|
||||
|
||||
@@ -303,15 +332,20 @@ async def combat_action(
|
||||
exclude_player_id=player['id']
|
||||
)
|
||||
else:
|
||||
# Fetch fresh combat state to capture any player buffs applied
|
||||
fresh_combat = await db.get_active_combat(player['id'])
|
||||
st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '')
|
||||
|
||||
# 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_status_effects': st_effects},
|
||||
npc_def,
|
||||
reduce_armor_durability,
|
||||
redis_manager
|
||||
redis_manager,
|
||||
locale=locale
|
||||
)
|
||||
messages.extend(npc_msgs)
|
||||
|
||||
@@ -336,6 +370,7 @@ async def combat_action(
|
||||
items_manager=ITEMS_MANAGER,
|
||||
reduce_armor_func=reduce_armor_durability,
|
||||
redis_manager=redis_manager,
|
||||
locale=locale
|
||||
)
|
||||
|
||||
if result.get('error'):
|
||||
@@ -372,21 +407,28 @@ async def combat_action(
|
||||
exclude_player_id=player['id']
|
||||
)
|
||||
else:
|
||||
# Fetch fresh combat state to capture effects applied by the skill
|
||||
fresh_combat = await db.get_active_combat(player['id'])
|
||||
st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '')
|
||||
|
||||
# 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_status_effects': st_effects},
|
||||
npc_def,
|
||||
reduce_armor_durability,
|
||||
redis_manager
|
||||
redis_manager,
|
||||
locale=locale
|
||||
)
|
||||
messages.extend(npc_msgs)
|
||||
|
||||
if player_defeated:
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
combat_over = True
|
||||
else:
|
||||
await db.update_combat(player['id'], {'npc_hp': new_npc_hp})
|
||||
|
||||
# ── USE ITEM ──
|
||||
elif req.action == 'use_item':
|
||||
@@ -421,15 +463,20 @@ async def combat_action(
|
||||
messages.extend(victory['messages'])
|
||||
quest_updates = victory.get('quest_updates', [])
|
||||
elif not combat_over:
|
||||
# Fetch fresh combat state to capture effects applied by the item
|
||||
fresh_combat = await db.get_active_combat(player['id'])
|
||||
st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '')
|
||||
|
||||
# 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_status_effects': st_effects},
|
||||
npc_def,
|
||||
reduce_armor_durability,
|
||||
redis_manager
|
||||
redis_manager,
|
||||
locale=locale
|
||||
)
|
||||
messages.extend(npc_msgs)
|
||||
|
||||
@@ -440,6 +487,38 @@ async def combat_action(
|
||||
# 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']})
|
||||
|
||||
# ── DEFEND ──
|
||||
elif req.action == 'defend':
|
||||
result = await combat_engine.execute_defend(
|
||||
player_id=player['id'],
|
||||
player=player,
|
||||
player_stats=stats,
|
||||
is_pvp=False,
|
||||
locale=locale,
|
||||
)
|
||||
messages.extend(result['messages'])
|
||||
|
||||
# Fetch fresh combat state since defend could've updated stats (stamina)
|
||||
fresh_combat = await db.get_active_combat(player['id'])
|
||||
st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '')
|
||||
|
||||
# NPC turn after defend
|
||||
npc_msgs, player_defeated = await combat_engine.execute_npc_turn(
|
||||
player['id'],
|
||||
{'npc_hp': combat['npc_hp'], 'npc_max_hp': combat['npc_max_hp'],
|
||||
'npc_intent': combat.get('npc_intent', 'attack'),
|
||||
'npc_status_effects': st_effects},
|
||||
npc_def,
|
||||
reduce_armor_durability,
|
||||
redis_manager,
|
||||
locale=locale
|
||||
)
|
||||
messages.extend(npc_msgs)
|
||||
|
||||
if player_defeated:
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
combat_over = True
|
||||
|
||||
# ── FLEE ──
|
||||
elif req.action == 'flee':
|
||||
@@ -491,6 +570,7 @@ async def combat_action(
|
||||
|
||||
# ── Build response ──
|
||||
updated_combat = None
|
||||
npc_effects_list = []
|
||||
if not combat_over:
|
||||
raw_combat = await db.get_active_combat(current_user['id'])
|
||||
if raw_combat:
|
||||
@@ -499,6 +579,23 @@ async def combat_action(
|
||||
turn_started_at = raw_combat.get('turn_started_at', 0)
|
||||
turn_time_remaining = max(0, 300 - (time.time() - turn_started_at))
|
||||
|
||||
# Parse NPC status effects string into a list
|
||||
npc_status_str = raw_combat.get('npc_status_effects', '') or ''
|
||||
if npc_status_str:
|
||||
for part in npc_status_str.split('|'):
|
||||
tokens = part.split(':')
|
||||
effect_name = tokens[0] if len(tokens) > 0 else ''
|
||||
if not effect_name:
|
||||
continue
|
||||
ticks = int(tokens[2]) if len(tokens) > 2 else (int(tokens[1]) if len(tokens) > 1 else 0)
|
||||
info = status_effects_manager.get_effect_info(effect_name)
|
||||
npc_effects_list.append({
|
||||
'name': info['name'],
|
||||
'icon': info['icon'],
|
||||
'ticks_remaining': ticks,
|
||||
'description': info['description'],
|
||||
})
|
||||
|
||||
updated_combat = {
|
||||
"npc_id": raw_combat['npc_id'],
|
||||
"npc_name": npc_def.name,
|
||||
@@ -507,13 +604,75 @@ async def combat_action(
|
||||
"npc_image": f"{npc_def.image_path}",
|
||||
"turn": raw_combat['turn'],
|
||||
"round": raw_combat.get('round', 1),
|
||||
"turn_time_remaining": turn_time_remaining
|
||||
"turn_time_remaining": turn_time_remaining,
|
||||
"npc_effects": npc_effects_list,
|
||||
"npc_intent": raw_combat.get('npc_intent', 'attack')
|
||||
}
|
||||
|
||||
# Get player active buffs/debuffs (exclude cooldowns)
|
||||
player_effects = []
|
||||
if not combat_over:
|
||||
from ..services.skills import skills_manager
|
||||
all_effects = await db.get_player_effects(current_user['id'])
|
||||
for eff in all_effects:
|
||||
if eff.get('effect_type') == 'cooldown':
|
||||
continue
|
||||
resolved = status_effects_manager.resolve_player_effect(
|
||||
eff.get('effect_name', ''),
|
||||
eff.get('effect_icon', '⚡'),
|
||||
eff.get('source', ''),
|
||||
skills_manager
|
||||
)
|
||||
player_effects.append({
|
||||
'name': resolved['name'],
|
||||
'icon': resolved['icon'],
|
||||
'ticks_remaining': eff.get('ticks_remaining', 0),
|
||||
'type': eff.get('effect_type', 'buff'),
|
||||
'description': resolved['description'],
|
||||
})
|
||||
|
||||
updated_player = await db.get_player_by_id(current_user['id'])
|
||||
if not updated_player:
|
||||
updated_player = current_user
|
||||
|
||||
equipment_slots = await db.get_all_equipment(current_user['id'])
|
||||
equipment = {}
|
||||
for slot, item_data in equipment_slots.items():
|
||||
if item_data and item_data['item_id']:
|
||||
inv_item = await db.get_inventory_item_by_id(item_data['item_id'])
|
||||
if inv_item:
|
||||
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||
if item_def:
|
||||
# Get unique item data if this is a unique item
|
||||
durability = None
|
||||
max_durability = None
|
||||
tier = None
|
||||
unique_stats = None
|
||||
if inv_item.get('unique_item_id'):
|
||||
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
|
||||
if unique_item:
|
||||
durability = unique_item.get('durability')
|
||||
max_durability = unique_item.get('max_durability')
|
||||
tier = unique_item.get('tier')
|
||||
unique_stats = unique_item.get('unique_stats')
|
||||
|
||||
equipment[slot] = {
|
||||
"inventory_id": item_data['item_id'],
|
||||
"item_id": item_def.id,
|
||||
"name": item_def.name,
|
||||
"description": item_def.description,
|
||||
"emoji": item_def.emoji,
|
||||
"image_path": item_def.image_path,
|
||||
"durability": durability if durability is not None else None,
|
||||
"max_durability": max_durability if max_durability is not None else None,
|
||||
"tier": tier if tier is not None else None,
|
||||
"unique_stats": unique_stats,
|
||||
"stats": item_def.stats,
|
||||
"encumbrance": item_def.encumbrance,
|
||||
"weapon_effects": item_def.weapon_effects if hasattr(item_def, 'weapon_effects') else {}
|
||||
}
|
||||
if slot not in equipment:
|
||||
equipment[slot] = None
|
||||
return {
|
||||
"success": True,
|
||||
"messages": messages,
|
||||
@@ -526,6 +685,8 @@ async def combat_action(
|
||||
"xp": updated_player['xp'],
|
||||
"level": updated_player['level']
|
||||
},
|
||||
"player_effects": player_effects,
|
||||
"equipment": equipment,
|
||||
"quest_updates": quest_updates
|
||||
}
|
||||
|
||||
@@ -887,6 +1048,7 @@ async def pvp_combat_action(
|
||||
items_manager=ITEMS_MANAGER,
|
||||
reduce_armor_func=reduce_armor_durability,
|
||||
redis_manager=redis_manager,
|
||||
locale=locale
|
||||
)
|
||||
|
||||
if result.get('error'):
|
||||
@@ -978,6 +1140,25 @@ async def pvp_combat_action(
|
||||
'last_action': f"{last_action_text}|{time.time()}"
|
||||
})
|
||||
|
||||
# ── DEFEND ──
|
||||
elif req.action == 'defend':
|
||||
result = await combat_engine.execute_defend(
|
||||
player_id=current_player['id'],
|
||||
player=current_player,
|
||||
player_stats=current_player_stats,
|
||||
is_pvp=True,
|
||||
locale=locale,
|
||||
)
|
||||
messages.extend(result['messages'])
|
||||
last_action_text = f"{current_player['name']} took a defensive stance!"
|
||||
|
||||
# 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()}"
|
||||
})
|
||||
|
||||
# ── FLEE ──
|
||||
elif req.action == 'flee':
|
||||
result = await combat_engine.execute_flee_pvp(
|
||||
|
||||
@@ -25,13 +25,15 @@ logger = logging.getLogger(__name__)
|
||||
LOCATIONS = None
|
||||
ITEMS_MANAGER = None
|
||||
WORLD = None
|
||||
redis_manager = None
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world):
|
||||
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
||||
"""Initialize router with game data dependencies"""
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
redis_manager = redis_mgr
|
||||
|
||||
router = APIRouter(tags=["crafting"])
|
||||
|
||||
@@ -509,9 +511,8 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
||||
adjusted_quantity = int(round(base_quantity * durability_ratio))
|
||||
|
||||
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
|
||||
mat_name = mat_def.name if mat_def else material['item_id']
|
||||
|
||||
loss_key = (material['item_id'], mat_name)
|
||||
loss_key = material['item_id']
|
||||
|
||||
# If durability is too low (< 10%), yield nothing for this material
|
||||
if durability_ratio < 0.1 or adjusted_quantity <= 0:
|
||||
@@ -535,7 +536,7 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
||||
# But we need to check capacity.
|
||||
# Let's accumulate pending yield.
|
||||
|
||||
yield_key = (material['item_id'], mat_name, mat_def.emoji if mat_def else '📦', mat_def)
|
||||
yield_key = material['item_id']
|
||||
if yield_key not in materials_yielded_dict:
|
||||
materials_yielded_dict[yield_key] = 0
|
||||
materials_yielded_dict[yield_key] += adjusted_quantity
|
||||
@@ -546,18 +547,23 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
|
||||
materials_dropped = []
|
||||
|
||||
# Convert lost dict to list
|
||||
for (item_id, name), qty in materials_lost_dict.items():
|
||||
for item_id, qty in materials_lost_dict.items():
|
||||
mat_def = ITEMS_MANAGER.items.get(item_id)
|
||||
materials_lost.append({
|
||||
'item_id': item_id,
|
||||
'name': name,
|
||||
'quantity': qty,
|
||||
'reason': 'lost_or_low_durability'
|
||||
'name': mat_def.name if mat_def else item_id,
|
||||
'emoji': mat_def.emoji if mat_def else '📦',
|
||||
'quantity': qty
|
||||
})
|
||||
|
||||
# Process yield
|
||||
for (item_id, name, emoji, mat_def), qty in materials_yielded_dict.items():
|
||||
mat_weight = getattr(mat_def, 'weight', 0) * qty
|
||||
mat_volume = getattr(mat_def, 'volume', 0) * qty
|
||||
for item_id, qty in materials_yielded_dict.items():
|
||||
mat_def = ITEMS_MANAGER.items.get(item_id)
|
||||
mat_name = mat_def.name if mat_def else item_id
|
||||
emoji = mat_def.emoji if mat_def else '📦'
|
||||
|
||||
mat_weight = getattr(mat_def, 'weight', 0) * qty if mat_def else 0
|
||||
mat_volume = getattr(mat_def, 'volume', 0) * qty if mat_def else 0
|
||||
|
||||
# Simple check against capacity (assuming current_weight was just updated from DB)
|
||||
# Note: we might fill up mid-loop. ideally we add one by one or check total.
|
||||
|
||||
@@ -50,6 +50,14 @@ async def equip_item(
|
||||
player_id = current_user['id']
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Check if in combat
|
||||
in_combat = await db.get_active_combat(player_id)
|
||||
if in_combat:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=get_game_message('cannot_equip_combat', locale)
|
||||
)
|
||||
|
||||
# Get the inventory item
|
||||
inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id)
|
||||
if not inv_item or inv_item['character_id'] != player_id:
|
||||
@@ -156,6 +164,14 @@ async def unequip_item(
|
||||
player_id = current_user['id']
|
||||
locale = request.headers.get('Accept-Language', 'en')
|
||||
|
||||
# Check if in combat
|
||||
in_combat = await db.get_active_combat(player_id)
|
||||
if in_combat:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=get_game_message('cannot_equip_combat', locale)
|
||||
)
|
||||
|
||||
# Check if slot is valid
|
||||
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
|
||||
if unequip_req.slot not in valid_slots:
|
||||
@@ -412,7 +428,7 @@ async def repair_item(
|
||||
|
||||
|
||||
|
||||
async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple:
|
||||
async def reduce_armor_durability(player_id: int, damage_taken: int, is_defending: bool = False) -> tuple:
|
||||
"""
|
||||
Reduce durability of equipped armor pieces when taking damage.
|
||||
Formula: durability_loss = max(1, (damage_taken / armor_value) * base_reduction_rate)
|
||||
@@ -452,7 +468,7 @@ async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple:
|
||||
|
||||
# Calculate durability loss for each armor piece
|
||||
# Balanced formula: armor should last many combats (10-20+ hits for low tier)
|
||||
base_reduction_rate = 0.1 # Reduced from 0.5 to make armor more durable
|
||||
base_reduction_rate = 0.2 if is_defending else 0.1 # Reduced from 0.5 to make armor more durable
|
||||
broken_armor = []
|
||||
|
||||
for armor in equipped_armor:
|
||||
|
||||
@@ -228,7 +228,8 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
# Get player status effects
|
||||
status_effects = await db.get_player_effects(player_id)
|
||||
from ..services.helpers import get_resolved_player_effects
|
||||
status_effects = await get_resolved_player_effects(player_id)
|
||||
player['status_effects'] = status_effects
|
||||
|
||||
# Get location
|
||||
@@ -375,13 +376,21 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
|
||||
"tags": getattr(location, 'tags', [])
|
||||
}
|
||||
|
||||
from ..services.stats import calculate_derived_stats
|
||||
derived_stats = await calculate_derived_stats(player_id, redis_manager)
|
||||
|
||||
# Add weight/volume to player data
|
||||
player_with_capacity = dict(player)
|
||||
player_with_capacity['current_weight'] = round(total_weight, 2)
|
||||
player_with_capacity['max_weight'] = round(max_weight, 2)
|
||||
player_with_capacity['current_volume'] = round(total_volume, 2)
|
||||
|
||||
player_with_capacity['max_weight'] = round(derived_stats.get('carry_weight', max_weight), 2)
|
||||
player_with_capacity['max_volume'] = round(max_volume, 2)
|
||||
|
||||
player_with_capacity['max_hp'] = derived_stats.get('max_hp', player['max_hp'])
|
||||
player_with_capacity['max_stamina'] = derived_stats.get('max_stamina', player['max_stamina'])
|
||||
player_with_capacity['derived_stats'] = derived_stats
|
||||
|
||||
# Calculate movement cooldown
|
||||
import time
|
||||
current_time = time.time()
|
||||
@@ -412,20 +421,29 @@ async def get_player_profile(current_user: dict = Depends(get_current_user)):
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
|
||||
# Get player status effects
|
||||
status_effects = await db.get_player_effects(player_id)
|
||||
from ..services.helpers import get_resolved_player_effects
|
||||
status_effects = await get_resolved_player_effects(player_id)
|
||||
player['status_effects'] = status_effects
|
||||
|
||||
# Get capacity metrics (weight/volume) using the helper function
|
||||
# We don't need the inventory array itself, just the capacity calculations
|
||||
_, total_weight, total_volume, max_weight, max_volume = await _get_enriched_inventory(player_id)
|
||||
|
||||
from ..services.stats import calculate_derived_stats
|
||||
derived_stats = await calculate_derived_stats(player_id, redis_manager)
|
||||
|
||||
# Add weight/volume to player data
|
||||
player_with_capacity = dict(player)
|
||||
player_with_capacity['current_weight'] = round(total_weight, 2)
|
||||
player_with_capacity['max_weight'] = round(max_weight, 2)
|
||||
player_with_capacity['current_volume'] = round(total_volume, 2)
|
||||
|
||||
player_with_capacity['max_weight'] = round(derived_stats.get('carry_weight', max_weight), 2)
|
||||
player_with_capacity['max_volume'] = round(max_volume, 2)
|
||||
|
||||
player_with_capacity['max_hp'] = derived_stats.get('max_hp', player['max_hp'])
|
||||
player_with_capacity['max_stamina'] = derived_stats.get('max_stamina', player['max_stamina'])
|
||||
player_with_capacity['derived_stats'] = derived_stats
|
||||
|
||||
# Calculate movement cooldown
|
||||
import time
|
||||
current_time = time.time()
|
||||
@@ -962,6 +980,7 @@ async def move(
|
||||
await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True)
|
||||
|
||||
encounter_triggered = True
|
||||
from ..services.helpers import get_resolved_player_effects
|
||||
combat_data = {
|
||||
"npc_id": enemy_id,
|
||||
"npc_name": npc_def.name,
|
||||
@@ -972,6 +991,7 @@ async def move(
|
||||
"round": 1,
|
||||
"npc_intent": initial_intent['type']
|
||||
}
|
||||
player_effects = await get_resolved_player_effects(current_user['id'], in_combat=True)
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
@@ -986,7 +1006,8 @@ async def move(
|
||||
"triggered": True,
|
||||
"enemy_id": enemy_id,
|
||||
"message": get_game_message('enemy_ambush', locale),
|
||||
"combat": combat_data
|
||||
"combat": combat_data,
|
||||
"player_effects": player_effects
|
||||
}
|
||||
|
||||
# Broadcast movement to WebSocket clients
|
||||
@@ -1585,6 +1606,10 @@ async def get_character_sheet(current_user: dict = Depends(get_current_user)):
|
||||
# Get all perks with availability
|
||||
all_perks = perks_manager.get_available_perks(player, owned_perk_ids)
|
||||
|
||||
# Get active status effects
|
||||
from ..services.helpers import get_resolved_player_effects
|
||||
status_effects = await get_resolved_player_effects(character_id)
|
||||
|
||||
# Calculate perk points
|
||||
total_perk_points = get_total_perk_points(player['level'])
|
||||
used_perk_points = len(owned_perk_ids)
|
||||
@@ -1607,6 +1632,7 @@ async def get_character_sheet(current_user: dict = Depends(get_current_user)):
|
||||
"used_points": used_perk_points,
|
||||
"all_perks": all_perks,
|
||||
},
|
||||
"status_effects": status_effects,
|
||||
"character": {
|
||||
"name": player['name'],
|
||||
"level": player['level'],
|
||||
|
||||
@@ -25,13 +25,15 @@ logger = logging.getLogger(__name__)
|
||||
LOCATIONS = None
|
||||
ITEMS_MANAGER = None
|
||||
WORLD = None
|
||||
redis_manager = None
|
||||
|
||||
def init_router_dependencies(locations, items_manager, world):
|
||||
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
|
||||
"""Initialize router with game data dependencies"""
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD
|
||||
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
|
||||
LOCATIONS = locations
|
||||
ITEMS_MANAGER = items_manager
|
||||
WORLD = world
|
||||
redis_manager = redis_mgr
|
||||
|
||||
router = APIRouter(tags=["loot"])
|
||||
|
||||
|
||||
@@ -208,8 +208,18 @@ async def execute_attack(
|
||||
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)
|
||||
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
|
||||
@@ -279,6 +289,54 @@ async def execute_attack(
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
@@ -294,6 +352,7 @@ async def execute_skill(
|
||||
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.
|
||||
@@ -402,6 +461,17 @@ async def execute_skill(
|
||||
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
|
||||
@@ -412,7 +482,7 @@ async def execute_skill(
|
||||
|
||||
if is_pvp:
|
||||
# PvP: armor from equipment
|
||||
absorbed, broken_armor = await reduce_armor_func(target['id'], hit_dmg)
|
||||
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(
|
||||
@@ -450,8 +520,11 @@ async def execute_skill(
|
||||
"skill_heal", origin="player", heal=heal_amount, skill_icon="🩸"
|
||||
))
|
||||
|
||||
from .helpers import calculate_dynamic_status_damage
|
||||
# Poison DoT
|
||||
if 'poison_damage' in effects:
|
||||
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(
|
||||
@@ -459,14 +532,14 @@ async def execute_skill(
|
||||
effect_name="Poison",
|
||||
effect_icon="🧪",
|
||||
effect_type="damage",
|
||||
damage_per_tick=effects['poison_damage'],
|
||||
ticks_remaining=effects['poison_duration'],
|
||||
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:{effects['poison_damage']}:{effects['poison_duration']}"
|
||||
poison_str = f"poison:{poison_dmg}:{poison_dur}"
|
||||
existing = combat_state.get('npc_status_effects', '') or ''
|
||||
if existing:
|
||||
existing += '|' + poison_str
|
||||
@@ -476,7 +549,38 @@ async def execute_skill(
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"skill_effect", origin="player",
|
||||
message=f"🧪 Poisoned! ({effects['poison_damage']} dmg/turn)"
|
||||
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
|
||||
@@ -506,7 +610,7 @@ async def execute_skill(
|
||||
await db.update_combat(player_id, {'npc_status_effects': existing})
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"skill_effect", origin="player", message="💫 Stunned!"
|
||||
"skill_effect", origin="player", message=get_game_message('stunned_status', locale)
|
||||
))
|
||||
|
||||
# Weapon durability
|
||||
@@ -721,7 +825,13 @@ async def execute_use_item(
|
||||
# 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)}"
|
||||
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",
|
||||
@@ -1123,6 +1233,7 @@ async def execute_npc_turn(
|
||||
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.
|
||||
@@ -1145,7 +1256,7 @@ async def execute_npc_turn(
|
||||
|
||||
from ..game_logic import npc_attack
|
||||
messages, player_defeated = await npc_attack(
|
||||
player_id, combat, npc_def, reduce_armor_func, player_stats=stats
|
||||
player_id, combat, npc_def, reduce_armor_func, player_stats=stats, locale=locale
|
||||
)
|
||||
|
||||
return messages, player_defeated
|
||||
|
||||
@@ -3,11 +3,27 @@ Helper utilities for game calculations and common operations.
|
||||
Contains distance calculations, stamina costs, capacity calculations, etc.
|
||||
"""
|
||||
import math
|
||||
from typing import Tuple, List, Dict, Any, Union
|
||||
import random
|
||||
from typing import Tuple, List, Dict, Any, Union, Optional
|
||||
from .. import database as db
|
||||
from ..items import ItemsManager
|
||||
|
||||
|
||||
def calculate_dynamic_status_damage(effects: dict, prefix: str, target: dict) -> Optional[int]:
|
||||
"""Helper to calculate status damage based on percentage over max HP."""
|
||||
if f'{prefix}_percent' in effects:
|
||||
target_max_hp = target.get('max_hp') or target.get('npc_max_hp', 100)
|
||||
pct = effects[f'{prefix}_percent']
|
||||
base_dmg = target_max_hp * pct
|
||||
# +/- 20% deviation
|
||||
min_dmg = max(1, int(base_dmg * 0.8))
|
||||
max_dmg = max(1, int(base_dmg * 1.2))
|
||||
return random.randint(min_dmg, max_dmg)
|
||||
elif f'{prefix}_damage' in effects:
|
||||
return effects[f'{prefix}_damage']
|
||||
return None
|
||||
|
||||
|
||||
def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> str:
|
||||
"""Helper to safely get string from i18n object or string."""
|
||||
if isinstance(value, dict):
|
||||
@@ -54,6 +70,8 @@ GAME_MESSAGES = {
|
||||
'pvp_combat_started_broadcast': {'en': "⚔️ PvP combat started between {attacker} and {defender}!", 'es': "⚔️ ¡Combate PvP iniciado entre {attacker} y {defender}!"},
|
||||
'flee_success_text': {'en': "{name} fled from combat!", 'es': "¡{name} huyó del combate!"},
|
||||
'flee_fail_text': {'en': "{name} tried to flee but failed!", 'es': "¡{name} intentó huir pero falló!"},
|
||||
'stunned_status': {'en': "💫 Stunned!", 'es': "💫 ¡Aturdido!"},
|
||||
'npc_stunned_cannot_act': {'en': "💫 {npc_name} is stunned and cannot act!", 'es': "💫 ¡{npc_name} está aturdido y no puede actuar!"},
|
||||
|
||||
# Loot
|
||||
'corpse_name_npc': {'en': "{name} Corpse", 'es': "Cadáver de {name}"},
|
||||
@@ -101,12 +119,16 @@ GAME_MESSAGES = {
|
||||
'item_used': {'en': "Used {name}", 'es': "Usado {name}"},
|
||||
'no_item': {'en': "You don't have this item", 'es': "No tienes este objeto"},
|
||||
'cannot_use': {'en': "This item cannot be used", 'es': "Este objeto no se puede usar"},
|
||||
'cannot_equip_combat': {'en': "Cannot change equipment during combat", 'es': "No puedes cambiar de equipamiento durante el combate"},
|
||||
'cured': {'en': "Cured", 'es': "Curado"},
|
||||
|
||||
# Status Effects
|
||||
'statusDamage': {'en': "You took {damage} damage from status effects", 'es': "Has recibido {damage} de daño por efectos de estado"},
|
||||
'statusHeal': {'en': "You recovered {heal} HP from status effects", 'es': "Has recuperado {heal} vida por efectos de estado"},
|
||||
'diedFromStatus': {'en': "You died from status effects", 'es': "Has muerto por efectos de estado"},
|
||||
|
||||
# Combat Warnings
|
||||
'enemy_charging': {'en': "⚠️ {enemy} is gathering strength for a massive attack!", 'es': "⚠️ ¡{enemy} está reuniendo fuerzas para un ataque masivo!"},
|
||||
}
|
||||
|
||||
def get_game_message(key: str, lang: str = 'en', **kwargs) -> str:
|
||||
@@ -140,6 +162,36 @@ def translate_travel_message(direction: str, location_name: str, lang: str = 'en
|
||||
|
||||
|
||||
import json
|
||||
from typing import List, Dict, Any, Tuple
|
||||
from .. import database as db
|
||||
|
||||
async def get_resolved_player_effects(player_id: int, in_combat: bool = False) -> List[Dict]:
|
||||
"""Helper to fetch and format active player effects for combat payloads."""
|
||||
from ..services.skills import skills_manager
|
||||
from ..services.status_effects import status_effects_manager
|
||||
|
||||
player_effects = []
|
||||
all_effects = await db.get_player_effects(player_id)
|
||||
for eff in all_effects:
|
||||
if eff.get('effect_type') == 'cooldown':
|
||||
continue
|
||||
resolved = status_effects_manager.resolve_player_effect(
|
||||
eff.get('effect_name', ''),
|
||||
eff.get('effect_icon', '⚡'),
|
||||
eff.get('source', ''),
|
||||
skills_manager,
|
||||
in_combat=in_combat
|
||||
)
|
||||
player_effects.append({
|
||||
'name': resolved['name'],
|
||||
'effect_name': eff.get('effect_name', ''), # Needed for frontend state tracking
|
||||
'icon': resolved['icon'],
|
||||
'ticks_remaining': eff.get('ticks_remaining', 0),
|
||||
'damage_per_tick': eff.get('damage_per_tick', 0), # Needed for logic
|
||||
'type': eff.get('effect_type', 'buff'),
|
||||
'description': resolved['description'],
|
||||
})
|
||||
return player_effects
|
||||
|
||||
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> dict:
|
||||
"""Create a structured combat message object.
|
||||
@@ -274,7 +326,7 @@ async def calculate_player_capacity(inventory: List[Dict[str, Any]], items_manag
|
||||
return current_weight, max_weight, current_volume, max_volume
|
||||
|
||||
|
||||
async def reduce_armor_durability(player_id: int, damage_taken: int, items_manager: ItemsManager) -> Tuple[int, List[Dict[str, Any]]]:
|
||||
async def reduce_armor_durability(player_id: int, damage_taken: int, items_manager: ItemsManager, is_defending: bool = False) -> Tuple[int, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Reduce durability of equipped armor pieces when taking damage.
|
||||
Returns: (armor_damage_absorbed, broken_armor_pieces)
|
||||
@@ -311,7 +363,7 @@ async def reduce_armor_durability(player_id: int, damage_taken: int, items_manag
|
||||
armor_absorbed = min(damage_taken // 2, total_armor)
|
||||
|
||||
# Calculate durability loss for each armor piece
|
||||
base_reduction_rate = 0.1
|
||||
base_reduction_rate = 0.2 if is_defending else 0.1
|
||||
broken_armor = []
|
||||
|
||||
for armor in equipped_armor:
|
||||
|
||||
@@ -60,6 +60,10 @@ class SkillsManager:
|
||||
"""
|
||||
available = []
|
||||
for skill_id, skill in self.skills.items():
|
||||
# Skip NPC-only skills (assumed to be those with 0 stat threshold and level 1 requirement)
|
||||
if (skill.stat_threshold <= 0 and skill.level_requirement <= 1) or getattr(skill, 'npc_only', False):
|
||||
continue
|
||||
|
||||
stat_value = character.get(skill.stat_requirement, 0)
|
||||
level = character.get('level', 1)
|
||||
|
||||
|
||||
@@ -36,7 +36,30 @@ async def calculate_derived_stats(character_id: int, redis_mgr=None) -> Dict[str
|
||||
if not char:
|
||||
return _empty_stats()
|
||||
|
||||
equipment = await db.get_all_equipment(character_id)
|
||||
raw_equipment = await db.get_all_equipment(character_id)
|
||||
enriched_equipment = {}
|
||||
|
||||
for slot, item_data in raw_equipment.items():
|
||||
if not item_data or not item_data.get('item_id'):
|
||||
continue
|
||||
|
||||
inv_item = await db.get_inventory_item_by_id(item_data['item_id'])
|
||||
if not inv_item:
|
||||
continue
|
||||
|
||||
enriched_item = {
|
||||
'item_id': inv_item['item_id'], # String ID
|
||||
'inventory_id': item_data['item_id']
|
||||
}
|
||||
|
||||
unique_item_id = inv_item.get('unique_item_id')
|
||||
if unique_item_id:
|
||||
unique_item = await db.get_unique_item(unique_item_id)
|
||||
if unique_item and unique_item.get('unique_stats'):
|
||||
enriched_item['unique_stats'] = unique_item['unique_stats']
|
||||
|
||||
enriched_equipment[slot] = enriched_item
|
||||
|
||||
effects = await db.get_player_effects(character_id)
|
||||
|
||||
# 3. Fetch owned perks
|
||||
@@ -44,7 +67,7 @@ async def calculate_derived_stats(character_id: int, redis_mgr=None) -> Dict[str
|
||||
owned_perk_ids = [row['perk_id'] for row in owned_perks]
|
||||
|
||||
# 4. Compute derived stats
|
||||
stats = _compute_stats(char, equipment, effects, owned_perk_ids)
|
||||
stats = _compute_stats(char, enriched_equipment, effects, owned_perk_ids)
|
||||
|
||||
# 5. Cache in Redis (5 min TTL)
|
||||
if redis_mgr and redis_mgr.redis_client:
|
||||
@@ -95,21 +118,29 @@ def _compute_stats(char: Dict[str, Any], equipment: Dict[str, Any], effects: Lis
|
||||
if not item_data or not item_data.get('item_id'):
|
||||
continue
|
||||
|
||||
# Get inventory item to find the item definition
|
||||
inv_item_sync = item_data # equipment dict already has item_id reference
|
||||
item_def = ITEMS_MANAGER.get_item(inv_item_sync.get('item_id', ''))
|
||||
item_id_str = item_data.get('item_id', '')
|
||||
item_def = ITEMS_MANAGER.get_item(item_id_str)
|
||||
|
||||
# Try to get item_id from the inventory item if the direct lookup failed
|
||||
if not item_def:
|
||||
continue
|
||||
|
||||
# Merge base stats and unique stats
|
||||
merged_stats = {}
|
||||
if item_def.stats:
|
||||
total_armor += item_def.stats.get('armor', 0)
|
||||
weapon_crit += item_def.stats.get('crit_chance', 0)
|
||||
merged_stats.update(item_def.stats)
|
||||
if item_data.get('unique_stats'):
|
||||
merged_stats.update(item_data['unique_stats'])
|
||||
|
||||
if merged_stats:
|
||||
total_armor += merged_stats.get('armor', 0)
|
||||
weapon_crit += merged_stats.get('crit_chance', 0)
|
||||
max_hp += merged_stats.get('max_hp', 0)
|
||||
max_stamina += merged_stats.get('max_stamina', 0)
|
||||
carry_weight += merged_stats.get('weight_capacity', 0)
|
||||
|
||||
if slot == 'weapon':
|
||||
weapon_damage_min = item_def.stats.get('damage_min', 0)
|
||||
weapon_damage_max = item_def.stats.get('damage_max', 0)
|
||||
weapon_damage_min = merged_stats.get('damage_min', 0)
|
||||
weapon_damage_max = merged_stats.get('damage_max', 0)
|
||||
|
||||
if slot == 'offhand':
|
||||
has_shield = True
|
||||
@@ -218,6 +249,28 @@ async def invalidate_stats_cache(character_id: int, redis_mgr=None):
|
||||
await redis_mgr.redis_client.delete(f"stats:{character_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sync derived max_hp and max_stamina to the database characters table
|
||||
try:
|
||||
derived = await calculate_derived_stats(character_id, redis_mgr)
|
||||
char = await db.get_player_by_id(character_id)
|
||||
if char:
|
||||
new_max_hp = derived.get('max_hp', char['max_hp'])
|
||||
new_max_stamina = derived.get('max_stamina', char['max_stamina'])
|
||||
|
||||
if new_max_hp != char['max_hp'] or new_max_stamina != char['max_stamina']:
|
||||
new_hp = min(char['hp'], new_max_hp)
|
||||
new_stamina = min(char['stamina'], new_max_stamina)
|
||||
await db.update_player(
|
||||
character_id,
|
||||
max_hp=new_max_hp,
|
||||
max_stamina=new_max_stamina,
|
||||
hp=new_hp,
|
||||
stamina=new_stamina
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to sync derived stats to DB for {character_id}: {e}")
|
||||
|
||||
|
||||
def get_flee_chance(flee_chance_base: float, enemy_level: int) -> float:
|
||||
|
||||
101
api/services/status_effects.py
Normal file
101
api/services/status_effects.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Status Effects Manager.
|
||||
Loads status effect definitions from gamedata/status_effects.json.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
class StatusEffect:
|
||||
"""Represents a status effect definition."""
|
||||
def __init__(self, effect_id: str, data: Dict[str, Any]):
|
||||
self.id = effect_id
|
||||
self.icon = data.get('icon', '⚡')
|
||||
self.name = data.get('name', effect_id.capitalize())
|
||||
self.description = data.get('description', effect_id.capitalize())
|
||||
self.type = data.get('type', 'debuff')
|
||||
|
||||
|
||||
class StatusEffectsManager:
|
||||
"""Manages status effect definitions loaded from JSON."""
|
||||
|
||||
def __init__(self, gamedata_path: str = "./gamedata"):
|
||||
self.effects: Dict[str, StatusEffect] = {}
|
||||
filepath = os.path.join(gamedata_path, 'status_effects.json')
|
||||
self._load(filepath)
|
||||
|
||||
def _load(self, filepath: str):
|
||||
"""Load status effects from a JSON file."""
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for effect_id, effect_data in data.get('effects', {}).items():
|
||||
self.effects[effect_id] = StatusEffect(effect_id, effect_data)
|
||||
print(f"✨ Loaded {len(self.effects)} status effects")
|
||||
except FileNotFoundError:
|
||||
print("⚠️ status_effects.json not found")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error loading status_effects.json: {e}")
|
||||
|
||||
def get_effect(self, effect_id: str) -> Optional[StatusEffect]:
|
||||
"""Get a status effect by its ID."""
|
||||
return self.effects.get(effect_id)
|
||||
|
||||
def get_effect_info(self, effect_id: str) -> Dict[str, Any]:
|
||||
"""Get effect info dict for API responses. Returns a fallback if not found."""
|
||||
effect = self.effects.get(effect_id)
|
||||
if effect:
|
||||
return {
|
||||
'name': effect.name,
|
||||
'icon': effect.icon,
|
||||
'description': effect.description,
|
||||
'type': effect.type,
|
||||
}
|
||||
# Fallback for unknown effects
|
||||
return {
|
||||
'name': {'en': effect_id.capitalize(), 'es': effect_id.capitalize()},
|
||||
'icon': '⚡',
|
||||
'description': {'en': effect_id.capitalize(), 'es': effect_id.capitalize()},
|
||||
'type': 'debuff',
|
||||
}
|
||||
|
||||
def resolve_player_effect(self, effect_name: str, effect_icon: str, source: str, skills_manager=None, in_combat: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolve translated name and description for a player effect.
|
||||
Tries skill source first, then status_effects.json, then fallback.
|
||||
"""
|
||||
translated_name = effect_name
|
||||
translated_desc = ''
|
||||
|
||||
# 1. Try to get from skill source (e.g., "skill:fortify")
|
||||
if source.startswith('skill:') and skills_manager:
|
||||
skill_id = source.split(':', 1)[1]
|
||||
skill_def = skills_manager.get_skill(skill_id)
|
||||
if skill_def:
|
||||
translated_name = skill_def.name
|
||||
translated_desc = skill_def.description
|
||||
|
||||
# 2. Try to get from status_effects.json by lowercased effect name
|
||||
if not translated_desc:
|
||||
effect_key = effect_name.lower()
|
||||
effect = self.effects.get(effect_key)
|
||||
if effect:
|
||||
translated_name = effect.name
|
||||
translated_desc = effect.description
|
||||
|
||||
# 3. Fallback: wrap the raw name as a translatable dict
|
||||
if not translated_desc:
|
||||
translated_desc = {'en': effect_name, 'es': effect_name}
|
||||
if isinstance(translated_name, str):
|
||||
translated_name = {'en': translated_name, 'es': translated_name}
|
||||
|
||||
return {
|
||||
'name': translated_name,
|
||||
'icon': effect_icon or '⚡',
|
||||
'description': translated_desc,
|
||||
}
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
status_effects_manager = StatusEffectsManager()
|
||||
Reference in New Issue
Block a user