feat(backend): Integrate Derived Stats into combat, loot, and crafting mechanics

This commit is contained in:
Joan
2026-02-25 10:05:14 +01:00
parent 185781d168
commit fd94387d54
10 changed files with 727 additions and 101 deletions

View File

@@ -208,12 +208,17 @@ async def interact_with_object(
items_dropped = []
damage_taken = outcome.damage_taken
# Calculate current capacity
# Calculate current capacity and fetch derived stats
from api.services.helpers import calculate_player_capacity
from api.services.stats import calculate_derived_stats
from api.items import items_manager as ITEMS_MANAGER
inventory = await db.get_inventory(player_id)
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
stats = await calculate_derived_stats(player_id)
loot_quality = stats.get('loot_quality', 1.0)
# Add items to inventory (or drop if over capacity)
for item_id, quantity in outcome.items_reward.items():
item = items_manager.get_item(item_id)
@@ -258,7 +263,12 @@ async def interact_with_object(
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
items_dropped.append(f"{emoji} {item_name}")
else:
# Stackable items - process as before
# Stackable items - apply loot quality bonus for resources and consumables
if getattr(item, 'category', item.type) in ['resource', 'consumable'] and loot_quality > 1.0:
bonus_chance = loot_quality - 1.0
if random.random() < bonus_chance:
quantity += 1
item_weight = item.weight * quantity
item_volume = item.volume * quantity
@@ -312,6 +322,15 @@ async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'e
if not player:
return {"success": False, "message": "Player not found"}
# Get derived stats for item effectiveness
# In some paths redis_manager might not be injected, so we attempt to fetch it from websockets module if needed,
# or let stats service fetch without cache
from api.services.stats import calculate_derived_stats
import api.core.websockets as ws
redis_mgr = getattr(ws.manager, 'redis_manager', None)
stats = await calculate_derived_stats(player['id'], redis_mgr)
item_effectiveness = stats.get('item_effectiveness', 1.0)
# Check if player has the item
inventory = await db.get_inventory(player_id)
item_entry = None
@@ -385,7 +404,8 @@ async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'e
# 3. Direct Healing (Legacy/Instant)
if 'hp_restore' in item.effects:
hp_restore = item.effects['hp_restore']
base_hp_restore = item.effects['hp_restore']
hp_restore = int(base_hp_restore * item_effectiveness)
old_hp = player['hp']
new_hp = min(player['max_hp'], old_hp + hp_restore)
actual_restored = new_hp - old_hp
@@ -395,7 +415,8 @@ async def use_item(player_id: int, item_id: str, items_manager, locale: str = 'e
effects_msg.append(f"+{actual_restored} HP")
if 'stamina_restore' in item.effects:
stamina_restore = item.effects['stamina_restore']
base_stamina_restore = item.effects['stamina_restore']
stamina_restore = int(base_stamina_restore * item_effectiveness)
old_stamina = player['stamina']
new_stamina = min(player['max_stamina'], old_stamina + stamina_restore)
actual_restored = new_stamina - old_stamina
@@ -532,6 +553,14 @@ async def check_and_apply_level_up(player_id: int) -> Dict[str, Any]:
unspent_points=new_unspent_points
)
# Invalidate cached derived stats (level affects max_hp, max_stamina, attack_power, etc.)
from api.services.stats import invalidate_stats_cache
try:
from api.core.websockets import manager as ws_manager
await invalidate_stats_cache(player_id, getattr(ws_manager, 'redis_manager', None))
except Exception:
pass
return {
"leveled_up": True,
"new_level": current_level,
@@ -588,7 +617,7 @@ def generate_npc_intent(npc_def, combat_state: dict) -> dict:
return intent
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -> Tuple[List[dict], bool]:
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, player_stats: dict = None) -> Tuple[List[dict], bool]:
"""
Execute NPC turn based on PREVIOUS intent, then generate NEXT intent.
Returns: (messages_list, player_defeated)
@@ -603,21 +632,38 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
npc_hp = combat['npc_hp']
npc_max_hp = combat['npc_max_hp']
npc_status_str = combat.get('npc_status_effects', '')
is_stunned = False
if npc_status_str:
# Parse status: "bleeding:5:3" (name:dmg:ticks) or multiple "bleeding:5:3|burning:2:2"
# 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
try:
parts = effect_str.split(':')
name = parts[0]
if name == 'stun' and len(parts) >= 2:
ticks = int(parts[1])
if ticks > 0:
is_stunned = True
messages.append(create_combat_message(
"skill_effect",
origin="enemy",
message=f"💫 {npc_def.name} is stunned and cannot act!"
))
ticks -= 1
if ticks > 0:
active_effects.append(f"stun:{ticks}")
continue
if len(parts) >= 3:
name = parts[0]
dmg = int(parts[1])
ticks = int(parts[2])
@@ -698,7 +744,7 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
actual_damage = 0
# EXECUTE INTENT
if npc_hp > 0: # Only attack if alive
if npc_hp > 0 and not is_stunned: # Only attack if alive and not stunned
if intent_type == 'defend':
# NPC defends - heals 5% HP
heal_amount = int(combat['npc_max_hp'] * 0.05)
@@ -765,28 +811,65 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -
# Remove defending effect after use
await db.remove_effect(player_id, 'defending')
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_attack",
origin="enemy",
npc_name=npc_def.name,
damage=npc_damage,
armor_absorbed=armor_absorbed
))
if broken_armor:
for armor in broken_armor:
# Check for dodge
dodged = False
if player_stats and 'dodge_chance' in player_stats:
if random.random() < player_stats['dodge_chance']:
dodged = True
messages.append(create_combat_message(
"item_broken",
origin="player",
item_name=armor['name'],
emoji=armor['emoji']
"combat_dodge",
origin="player"
))
# Prevent damage calculation
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"
))
# Apply blocked effect (damage reduced significantly or nullified)
npc_damage = max(1, int(npc_damage * 0.2)) # Block mitigates 80% damage
await db.update_player(player_id, hp=new_player_hp)
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)))
# Still show "armor_absorbed" conceptually for UI logs, though it's % based now
armor_absorbed_visual = npc_damage - actual_damage
else:
actual_damage = max(1, npc_damage - armor_absorbed)
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']
))
await db.update_player(player_id, hp=new_player_hp)
# GENERATE NEXT INTENT