Pre-menu-integration snapshot: combat, crafting, status effects, gamedata updates

This commit is contained in:
Joan
2026-03-11 12:43:23 +01:00
parent d5afd28eb9
commit a8dc8211d5
36 changed files with 1724 additions and 404 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View 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()