feat(backend): Integrate Derived Stats into combat, loot, and crafting mechanics
This commit is contained in:
@@ -250,6 +250,10 @@ async def combat_action(
|
||||
player = current_user # current_user is already the character dict
|
||||
npc_def = NPCS.get(combat['npc_id'])
|
||||
|
||||
# Get player derived stats
|
||||
from ..services.stats import calculate_derived_stats
|
||||
stats = await calculate_derived_stats(player['id'], redis_manager)
|
||||
|
||||
messages = []
|
||||
combat_over = False
|
||||
|
||||
@@ -290,7 +294,7 @@ async def combat_action(
|
||||
elif total_impact < 0:
|
||||
# HEALING
|
||||
heal = abs(total_impact)
|
||||
new_hp = min(player['max_hp'], player['hp'] + heal)
|
||||
new_hp = min(stats['max_hp'], player['hp'] + heal)
|
||||
actual_heal = new_hp - player['hp']
|
||||
|
||||
if actual_heal > 0:
|
||||
@@ -307,26 +311,20 @@ async def combat_action(
|
||||
|
||||
|
||||
if req.action == 'attack':
|
||||
# Calculate player damage
|
||||
base_damage = 5
|
||||
strength_bonus = player['strength'] // 2
|
||||
level_bonus = player['level']
|
||||
weapon_damage = 0
|
||||
# Calculate player damage using derived stats
|
||||
base_damage = stats.get('attack_power', 5)
|
||||
weapon_effects = {}
|
||||
weapon_inv_id = None
|
||||
|
||||
# Check for equipped weapon
|
||||
# Check for equipped weapon to apply durability loss and effects
|
||||
# (Attack power from the weapon is already included in stats['attack_power'])
|
||||
equipment = await db.get_all_equipment(player['id'])
|
||||
if equipment.get('weapon') and equipment['weapon']:
|
||||
weapon_slot = equipment['weapon']
|
||||
inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id'])
|
||||
if inv_item:
|
||||
weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||
if weapon_def and weapon_def.stats:
|
||||
weapon_damage = random.randint(
|
||||
weapon_def.stats.get('damage_min', 0),
|
||||
weapon_def.stats.get('damage_max', 0)
|
||||
)
|
||||
if weapon_def:
|
||||
weapon_effects = weapon_def.weapon_effects if hasattr(weapon_def, 'weapon_effects') else {}
|
||||
weapon_inv_id = weapon_slot['item_id']
|
||||
|
||||
@@ -339,7 +337,7 @@ async def combat_action(
|
||||
attack_failed = True
|
||||
|
||||
variance = random.randint(-2, 2)
|
||||
damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
||||
damage = max(1, base_damage + variance)
|
||||
|
||||
if attack_failed:
|
||||
messages.append(create_combat_message(
|
||||
@@ -349,12 +347,31 @@ async def combat_action(
|
||||
))
|
||||
new_npc_hp = combat['npc_hp']
|
||||
else:
|
||||
# Check for critical hit
|
||||
is_critical = False
|
||||
crit_chance = stats.get('crit_chance', 0.05)
|
||||
if random.random() < crit_chance:
|
||||
is_critical = True
|
||||
damage = int(damage * stats.get('crit_damage', 1.5))
|
||||
|
||||
# Apply NPC defense reduction
|
||||
npc_defense = getattr(npc_def, 'defense', 0)
|
||||
actual_damage = max(1, damage - npc_defense)
|
||||
|
||||
# Apply damage to NPC
|
||||
new_npc_hp = max(0, combat['npc_hp'] - damage)
|
||||
new_npc_hp = max(0, combat['npc_hp'] - actual_damage)
|
||||
|
||||
if is_critical:
|
||||
messages.append(create_combat_message(
|
||||
"combat_crit",
|
||||
origin="player"
|
||||
))
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"player_attack",
|
||||
origin="player",
|
||||
damage=damage
|
||||
damage=actual_damage,
|
||||
armor_absorbed=npc_defense if npc_defense > 0 else 0
|
||||
))
|
||||
|
||||
# Apply weapon effects
|
||||
@@ -715,36 +732,278 @@ async def combat_action(
|
||||
await db.update_player_statistics(player['id'], failed_flees=1, damage_taken=npc_damage, increment=True)
|
||||
await db.update_combat(player['id'], {'turn': 'player', 'turn_started_at': time.time()})
|
||||
|
||||
elif req.action == 'defend':
|
||||
# Apply "defending" status effect - reduces incoming damage by 50% for 1 turn
|
||||
await db.add_effect(
|
||||
player_id=player['id'],
|
||||
effect_name='defending',
|
||||
effect_icon='🛡️',
|
||||
effect_type='buff',
|
||||
value=50, # 50% damage reduction
|
||||
ticks_remaining=1,
|
||||
persist_after_combat=False,
|
||||
source='action:defend'
|
||||
)
|
||||
elif req.action == 'skill':
|
||||
# ── SKILL ACTION ──
|
||||
if not req.skill_id:
|
||||
raise HTTPException(status_code=400, detail="skill_id required for skill action")
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"defend",
|
||||
origin="player",
|
||||
message=get_game_message('defend_text', locale, name=player['name'])
|
||||
))
|
||||
from ..services.skills import skills_manager
|
||||
skill = skills_manager.get_skill(req.skill_id)
|
||||
if not skill:
|
||||
raise HTTPException(status_code=404, detail="Skill not found")
|
||||
|
||||
# NPC's turn after defend
|
||||
npc_attack_messages, player_defeated = await game_logic.npc_attack(
|
||||
player['id'],
|
||||
{'npc_hp': combat['npc_hp'], 'npc_max_hp': combat['npc_max_hp']},
|
||||
npc_def,
|
||||
reduce_armor_durability
|
||||
)
|
||||
messages.extend(npc_attack_messages)
|
||||
if player_defeated:
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
# Check unlocked
|
||||
stat_val = player.get(skill.stat_requirement, 0)
|
||||
if stat_val < skill.stat_threshold or player['level'] < skill.level_requirement:
|
||||
raise HTTPException(status_code=400, detail="Skill not unlocked")
|
||||
|
||||
# Check cooldown
|
||||
active_effects = await db.get_player_effects(player['id'])
|
||||
cd_source = f"cd:{skill.id}"
|
||||
for eff in active_effects:
|
||||
if eff.get('source') == cd_source and eff.get('ticks_remaining', 0) > 0:
|
||||
raise HTTPException(status_code=400, detail=f"Skill on cooldown ({eff['ticks_remaining']} turns)")
|
||||
|
||||
# Check stamina
|
||||
if player['stamina'] < skill.stamina_cost:
|
||||
raise HTTPException(status_code=400, detail="Not enough stamina")
|
||||
|
||||
# Deduct stamina
|
||||
new_stamina = player['stamina'] - skill.stamina_cost
|
||||
await db.update_player_stamina(player['id'], new_stamina)
|
||||
player['stamina'] = new_stamina
|
||||
|
||||
# Add cooldown effect
|
||||
if skill.cooldown > 0:
|
||||
await db.add_effect(
|
||||
player_id=player['id'],
|
||||
effect_name=f"{skill.id}_cooldown",
|
||||
effect_icon="⏳",
|
||||
effect_type="cooldown",
|
||||
value=0,
|
||||
ticks_remaining=skill.cooldown,
|
||||
persist_after_combat=False,
|
||||
source=cd_source
|
||||
)
|
||||
|
||||
# Get weapon info
|
||||
equipment = await db.get_all_equipment(player['id'])
|
||||
weapon_damage = 0
|
||||
inv_item = None
|
||||
weapon_inv_id = None
|
||||
weapon_def = None
|
||||
if equipment.get('weapon') and equipment['weapon']:
|
||||
weapon_slot = equipment['weapon']
|
||||
inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id'])
|
||||
if inv_item:
|
||||
weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||
if weapon_def and weapon_def.stats:
|
||||
weapon_damage = random.randint(
|
||||
weapon_def.stats.get('damage_min', 0),
|
||||
weapon_def.stats.get('damage_max', 0)
|
||||
)
|
||||
weapon_inv_id = inv_item['id']
|
||||
|
||||
effects = skill.effects
|
||||
new_npc_hp = combat['npc_hp']
|
||||
combat_over = False
|
||||
player_won = False
|
||||
|
||||
# ── Damage skills ──
|
||||
if 'damage_multiplier' in effects:
|
||||
base_damage = 5
|
||||
strength_bonus = int(player['strength'] * 1.5)
|
||||
level_bonus = player['level']
|
||||
variance = random.randint(-2, 2)
|
||||
raw_damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
||||
|
||||
multiplier = effects['damage_multiplier']
|
||||
|
||||
# Execute check
|
||||
if 'execute_threshold' in effects:
|
||||
npc_hp_pct = combat['npc_hp'] / combat['npc_max_hp'] if combat['npc_max_hp'] > 0 else 1
|
||||
if npc_hp_pct <= effects['execute_threshold']:
|
||||
multiplier = effects.get('execute_multiplier', multiplier)
|
||||
|
||||
# Exploit Weakness check
|
||||
if effects.get('requires_analyzed'):
|
||||
# Check if NPC has been analyzed this combat
|
||||
analyzed = combat.get('npc_status_effects', '') or ''
|
||||
if 'analyzed' not in analyzed:
|
||||
multiplier = 1.0 # No bonus if not analyzed
|
||||
|
||||
damage = max(1, int(raw_damage * multiplier))
|
||||
|
||||
# Guaranteed crit
|
||||
if effects.get('guaranteed_crit'):
|
||||
damage = int(damage * 1.5)
|
||||
|
||||
# Multi-hit
|
||||
num_hits = effects.get('hits', 1)
|
||||
total_damage = 0
|
||||
|
||||
for hit in range(num_hits):
|
||||
hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier))
|
||||
|
||||
# Armor penetration
|
||||
npc_defense = getattr(npc_def, 'defense', 0)
|
||||
if 'armor_penetration' in effects:
|
||||
npc_defense = int(npc_defense * (1 - effects['armor_penetration']))
|
||||
|
||||
actual_hit = max(1, hit_dmg - npc_defense)
|
||||
total_damage += actual_hit
|
||||
new_npc_hp = max(0, new_npc_hp - actual_hit)
|
||||
|
||||
messages.append(create_combat_message(
|
||||
"skill_attack",
|
||||
origin="player",
|
||||
damage=total_damage,
|
||||
skill_name=skill.name,
|
||||
skill_icon=skill.icon,
|
||||
hits=num_hits
|
||||
))
|
||||
|
||||
# Lifesteal
|
||||
if 'lifesteal' in effects:
|
||||
heal_amount = int(total_damage * effects['lifesteal'])
|
||||
new_hp = min(player['max_hp'], player['hp'] + heal_amount)
|
||||
if new_hp > player['hp']:
|
||||
await db.update_player_hp(player['id'], new_hp)
|
||||
player['hp'] = new_hp
|
||||
messages.append(create_combat_message(
|
||||
"skill_heal", origin="player", heal=heal_amount, skill_icon="🩸"
|
||||
))
|
||||
|
||||
# Poison DoT
|
||||
if 'poison_damage' in effects:
|
||||
poison_str = f"poison:{effects['poison_damage']}:{effects['poison_duration']}"
|
||||
existing = combat.get('npc_status_effects', '') or ''
|
||||
if existing:
|
||||
existing += '|' + poison_str
|
||||
else:
|
||||
existing = poison_str
|
||||
await db.update_combat(player['id'], {'npc_status_effects': existing})
|
||||
messages.append(create_combat_message(
|
||||
"skill_effect", origin="player", message=f"🧪 Poisoned! ({effects['poison_damage']} dmg/turn)"
|
||||
))
|
||||
|
||||
# Stun chance
|
||||
if 'stun_chance' in effects and random.random() < effects['stun_chance']:
|
||||
messages.append(create_combat_message(
|
||||
"skill_effect", origin="player", message="💫 Stunned!"
|
||||
))
|
||||
|
||||
# Weapon durability
|
||||
if weapon_inv_id and inv_item and inv_item.get('unique_item_id'):
|
||||
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
|
||||
if new_durability is None:
|
||||
messages.append(create_combat_message("weapon_broke", origin="player", item_name=weapon_def.name if weapon_def else "weapon"))
|
||||
await db.unequip_item(player['id'], 'weapon')
|
||||
|
||||
# ── Heal skills ──
|
||||
if 'heal_percent' in effects:
|
||||
heal_amount = int(player['max_hp'] * effects['heal_percent'])
|
||||
new_hp = min(player['max_hp'], player['hp'] + heal_amount)
|
||||
actual = new_hp - player['hp']
|
||||
if actual > 0:
|
||||
await db.update_player_hp(player['id'], new_hp)
|
||||
player['hp'] = new_hp
|
||||
messages.append(create_combat_message(
|
||||
"skill_heal", origin="player", heal=actual, skill_name=skill.name, skill_icon=skill.icon
|
||||
))
|
||||
|
||||
# ── Stamina restore skills ──
|
||||
if 'stamina_restore_percent' in effects:
|
||||
restore = int(player['max_stamina'] * effects['stamina_restore_percent'])
|
||||
new_stam = min(player['max_stamina'], player['stamina'] + restore)
|
||||
actual = new_stam - player['stamina']
|
||||
if actual > 0:
|
||||
await db.update_player_stamina(player['id'], new_stam)
|
||||
player['stamina'] = new_stam
|
||||
messages.append(create_combat_message(
|
||||
"skill_effect", origin="player", message=f"⚡ +{actual} Stamina"
|
||||
))
|
||||
|
||||
# ── Buff skills ──
|
||||
if 'buff' in effects:
|
||||
buff_name_raw = effects['buff']
|
||||
duration = effects.get('buff_duration', 2)
|
||||
value = 0
|
||||
if 'damage_reduction' in effects:
|
||||
value = int(effects['damage_reduction'] * 100)
|
||||
elif 'damage_bonus' in effects:
|
||||
value = int(effects['damage_bonus'] * 100)
|
||||
|
||||
await db.add_effect(
|
||||
player_id=player['id'],
|
||||
effect_name=buff_name_raw,
|
||||
effect_icon=skill.icon,
|
||||
effect_type='buff',
|
||||
value=value,
|
||||
ticks_remaining=duration,
|
||||
persist_after_combat=False,
|
||||
source=f'skill:{skill.id}'
|
||||
)
|
||||
messages.append(create_combat_message(
|
||||
"skill_buff", origin="player",
|
||||
skill_name=skill.name, skill_icon=skill.icon, duration=duration
|
||||
))
|
||||
|
||||
# ── Analyze skill ──
|
||||
if effects.get('mark_analyzed'):
|
||||
existing = combat.get('npc_status_effects', '') or ''
|
||||
if 'analyzed' not in existing:
|
||||
if existing:
|
||||
existing += '|analyzed:0:99'
|
||||
else:
|
||||
existing = 'analyzed:0:99'
|
||||
await db.update_combat(player['id'], {'npc_status_effects': existing})
|
||||
|
||||
npc_hp_pct = int((combat['npc_hp'] / combat['npc_max_hp']) * 100) if combat['npc_max_hp'] > 0 else 0
|
||||
intent = combat.get('npc_intent', 'attack')
|
||||
messages.append(create_combat_message(
|
||||
"skill_analyze", origin="player",
|
||||
skill_icon=skill.icon,
|
||||
npc_name=npc_def.name,
|
||||
npc_hp_pct=npc_hp_pct,
|
||||
npc_intent=intent
|
||||
))
|
||||
|
||||
# Check NPC death
|
||||
if new_npc_hp <= 0:
|
||||
messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name))
|
||||
combat_over = True
|
||||
player_won = True
|
||||
|
||||
# Award XP
|
||||
xp_reward = npc_def.xp_reward
|
||||
current_xp = player['xp'] + xp_reward
|
||||
await db.update_player(player['id'], xp=current_xp)
|
||||
player['xp'] = current_xp
|
||||
|
||||
messages.append(create_combat_message("xp_gained", origin="neutral", xp=xp_reward))
|
||||
|
||||
await db.update_player_statistics(player['id'], enemies_killed=1, increment=True)
|
||||
|
||||
# Level up check
|
||||
level_result = await game_logic.check_and_apply_level_up(player['id'])
|
||||
if level_result['leveled_up']:
|
||||
messages.append(create_combat_message("level_up", origin="neutral", new_level=level_result['new_level']))
|
||||
|
||||
# Loot
|
||||
loot_items = npc_def.loot if hasattr(npc_def, 'loot') else []
|
||||
generated_loot = []
|
||||
if loot_items:
|
||||
for loot in loot_items:
|
||||
if random.random() < loot.get('chance', 1.0):
|
||||
qty = random.randint(loot.get('min', 1), loot.get('max', 1))
|
||||
# Only append message in combat log, actual items are in corpse
|
||||
messages.append(create_combat_message("loot", origin="neutral", item_id=loot['item_id'], quantity=qty))
|
||||
generated_loot.append({"item_id": loot['item_id'], "quantity": qty})
|
||||
|
||||
# Create corpse
|
||||
import json
|
||||
await db.create_npc_corpse(
|
||||
combat['npc_id'],
|
||||
combat.get('location_id', player.get('location_id', '')),
|
||||
json.dumps(generated_loot)
|
||||
)
|
||||
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
messages.extend(npc_attack_messages)
|
||||
if player_defeated:
|
||||
await db.remove_non_persistent_effects(player['id'])
|
||||
combat_over = True
|
||||
|
||||
elif req.action == 'use_item':
|
||||
combat_over = False
|
||||
@@ -791,9 +1050,10 @@ async def combat_action(
|
||||
# 1. Apply Status Effects (e.g. Regeneration from Bandage)
|
||||
if item_def.effects.get('status_effect'):
|
||||
status_data = item_def.effects['status_effect']
|
||||
status_name = status_data['name']
|
||||
await db.add_effect(
|
||||
player_id=player['id'],
|
||||
effect_name=status_data['name'],
|
||||
effect_name=status_name,
|
||||
effect_icon=status_data.get('icon', '✨'),
|
||||
effect_type=status_data.get('type', 'buff'),
|
||||
damage_per_tick=status_data.get('damage_per_tick', 0),
|
||||
@@ -813,7 +1073,10 @@ async def combat_action(
|
||||
|
||||
# 3. Handle Direct healing (legacy/instant)
|
||||
if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0:
|
||||
hp_restore = item_def.effects['hp_restore']
|
||||
item_effectiveness = stats.get('item_effectiveness', 1.0)
|
||||
base_hp_restore = item_def.effects['hp_restore']
|
||||
hp_restore = int(base_hp_restore * item_effectiveness)
|
||||
|
||||
old_hp = player['hp']
|
||||
new_hp = min(player.get('max_hp', 100), old_hp + hp_restore)
|
||||
actual_restored = new_hp - old_hp
|
||||
@@ -821,8 +1084,11 @@ async def combat_action(
|
||||
await db.update_player_hp(player['id'], new_hp)
|
||||
effects_applied.append(f"+{actual_restored} HP")
|
||||
|
||||
if item_def.effects.get('stamina_restore'):
|
||||
stamina_restore = item_def.effects['stamina_restore']
|
||||
if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0:
|
||||
item_effectiveness = stats.get('item_effectiveness', 1.0)
|
||||
base_stamina_restore = item_def.effects['stamina_restore']
|
||||
stamina_restore = int(base_stamina_restore * item_effectiveness)
|
||||
|
||||
old_stamina = player['stamina']
|
||||
new_stamina = min(player.get('max_stamina', 100), old_stamina + stamina_restore)
|
||||
actual_restored = new_stamina - old_stamina
|
||||
@@ -1259,6 +1525,12 @@ async def pvp_combat_action(
|
||||
current_player = attacker if is_attacker else defender
|
||||
opponent = defender if is_attacker else attacker
|
||||
|
||||
# Get derived stats for both players
|
||||
from ..services.stats import calculate_derived_stats
|
||||
current_player_stats = await calculate_derived_stats(current_player['id'], redis_manager)
|
||||
# Opponent stats won't be used for attack calculation but could be used for defense logic
|
||||
# opponent_stats = await calculate_derived_stats(opponent['id'], redis_manager)
|
||||
|
||||
messages = []
|
||||
combat_over = False
|
||||
winner_id = None
|
||||
@@ -1267,24 +1539,18 @@ async def pvp_combat_action(
|
||||
last_action_text = ""
|
||||
|
||||
if req.action == 'attack':
|
||||
# Calculate damage (similar to PvE)
|
||||
base_damage = 5
|
||||
strength_bonus = current_player['strength'] * 2
|
||||
level_bonus = current_player['level']
|
||||
# Calculate damage (unified formula with derived stats)
|
||||
base_damage = current_player_stats.get('attack_power', 5)
|
||||
|
||||
# Check for equipped weapon
|
||||
weapon_damage = 0
|
||||
# Check for equipped weapon to apply durability loss
|
||||
# (Attack power from the weapon is already included in stats['attack_power'])
|
||||
equipment = await db.get_all_equipment(current_player['id'])
|
||||
if equipment.get('weapon') and equipment['weapon']:
|
||||
weapon_slot = equipment['weapon']
|
||||
inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id'])
|
||||
if inv_item:
|
||||
weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
|
||||
if weapon_def and weapon_def.stats:
|
||||
weapon_damage = random.randint(
|
||||
weapon_def.stats.get('damage_min', 0),
|
||||
weapon_def.stats.get('damage_max', 0)
|
||||
)
|
||||
if weapon_def:
|
||||
# Decrease weapon durability
|
||||
if inv_item.get('unique_item_id'):
|
||||
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
|
||||
@@ -1297,17 +1563,31 @@ async def pvp_combat_action(
|
||||
await db.unequip_item(current_player['id'], 'weapon')
|
||||
|
||||
variance = random.randint(-2, 2)
|
||||
damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
|
||||
damage = max(1, base_damage + variance)
|
||||
|
||||
# Check for critical hit
|
||||
is_critical = False
|
||||
crit_chance = current_player_stats.get('crit_chance', 0.05)
|
||||
if random.random() < crit_chance:
|
||||
is_critical = True
|
||||
damage = int(damage * current_player_stats.get('crit_damage', 1.5))
|
||||
|
||||
# Apply armor reduction and durability loss to opponent
|
||||
armor_absorbed, broken_armor = await reduce_armor_durability(opponent['id'], damage)
|
||||
actual_damage = max(1, damage - armor_absorbed)
|
||||
|
||||
if is_critical:
|
||||
messages.append(create_combat_message(
|
||||
"combat_crit",
|
||||
origin="player"
|
||||
))
|
||||
last_action_text += f"\nCritical Hit! "
|
||||
|
||||
# Structure the attack message
|
||||
messages.append(create_combat_message(
|
||||
"player_attack",
|
||||
origin="player",
|
||||
damage=damage,
|
||||
damage=actual_damage,
|
||||
armor_absorbed=armor_absorbed
|
||||
))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user