diff --git a/api/game_logic.py b/api/game_logic.py index 869e9f8..ee46238 100644 --- a/api/game_logic.py +++ b/api/game_logic.py @@ -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 diff --git a/api/give_gear.py b/api/give_gear.py new file mode 100644 index 0000000..f125353 --- /dev/null +++ b/api/give_gear.py @@ -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()) diff --git a/api/give_gear_final.sql b/api/give_gear_final.sql new file mode 100644 index 0000000..6786f6a --- /dev/null +++ b/api/give_gear_final.sql @@ -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 $$; diff --git a/api/main.py b/api/main.py index e8bc24d..d27d40d 100644 --- a/api/main.py +++ b/api/main.py @@ -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) diff --git a/api/routers/combat.py b/api/routers/combat.py index 0f70d2f..428015a 100644 --- a/api/routers/combat.py +++ b/api/routers/combat.py @@ -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( diff --git a/api/routers/crafting.py b/api/routers/crafting.py index b6afbd5..fdaf352 100644 --- a/api/routers/crafting.py +++ b/api/routers/crafting.py @@ -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. diff --git a/api/routers/equipment.py b/api/routers/equipment.py index 6843fd4..d3ee33e 100644 --- a/api/routers/equipment.py +++ b/api/routers/equipment.py @@ -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: diff --git a/api/routers/game_routes.py b/api/routers/game_routes.py index 1b2d803..dbfd965 100644 --- a/api/routers/game_routes.py +++ b/api/routers/game_routes.py @@ -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'], diff --git a/api/routers/loot.py b/api/routers/loot.py index 86caf36..7ba073c 100644 --- a/api/routers/loot.py +++ b/api/routers/loot.py @@ -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"]) diff --git a/api/services/combat_engine.py b/api/services/combat_engine.py index 42b29bb..7242bb4 100644 --- a/api/services/combat_engine.py +++ b/api/services/combat_engine.py @@ -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 diff --git a/api/services/helpers.py b/api/services/helpers.py index efc9ec3..48a2f91 100644 --- a/api/services/helpers.py +++ b/api/services/helpers.py @@ -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: diff --git a/api/services/skills.py b/api/services/skills.py index a48210b..c822182 100644 --- a/api/services/skills.py +++ b/api/services/skills.py @@ -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) diff --git a/api/services/stats.py b/api/services/stats.py index d4fd6c8..c860d1a 100644 --- a/api/services/stats.py +++ b/api/services/stats.py @@ -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: diff --git a/api/services/status_effects.py b/api/services/status_effects.py new file mode 100644 index 0000000..4b6279e --- /dev/null +++ b/api/services/status_effects.py @@ -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() diff --git a/gamedata/items.json b/gamedata/items.json index 4dd62de..44273d3 100644 --- a/gamedata/items.json +++ b/gamedata/items.json @@ -897,7 +897,7 @@ "repair_percentage": 25, "stats": { "armor": 3, - "hp_bonus": 10 + "max_hp": 10 }, "emoji": "🦺", "image_path": "images/items/leather_vest.webp", @@ -988,7 +988,7 @@ "repair_percentage": 25, "stats": { "armor": 2, - "stamina_bonus": 5 + "max_stamina": 5 }, "emoji": "🥾", "image_path": "images/items/sturdy_boots.webp", @@ -1036,7 +1036,7 @@ "repair_percentage": 25, "stats": { "armor": 2, - "hp_bonus": 5 + "max_hp": 5 }, "emoji": "👖", "image_path": "images/items/padded_pants.webp", @@ -1261,7 +1261,7 @@ "status": { "name": "burning", "icon": "🔥", - "damage_per_tick": 3, + "damage_percent": 0.08, "ticks": 3, "persist_after_combat": true } @@ -1279,8 +1279,8 @@ "emoji": "💨", "image_path": "images/items/smoke_bomb.webp", "description": { - "en": "Creates a smoke screen. Greatly increases flee chance for 1 turn.", - "es": "Crea una cortina de humo. Aumenta la probabilidad de huir por 1 turno." + "en": "Creates a smoke screen. Greatly increases flee chance for 1 {{interval}}.", + "es": "Crea una cortina de humo. Aumenta la probabilidad de huir por 1 {{interval}}." }, "stackable": true, "combat_usable": true, @@ -1328,8 +1328,8 @@ "emoji": "⚡", "image_path": "images/items/adrenaline.webp", "description": { - "en": "Increases damage output for 2 turns. Only usable in combat.", - "es": "Aumenta el daño durante 2 turnos. Solo usable en combate." + "en": "Increases damage output for 2 {{intervals_plural}}. Only usable in combat.", + "es": "Aumenta el daño durante 2 {{intervals_plural}}. Solo usable en combate." }, "stackable": true, "consumable": true, diff --git a/gamedata/npcs.json b/gamedata/npcs.json index 706b3e3..302f5b5 100644 --- a/gamedata/npcs.json +++ b/gamedata/npcs.json @@ -47,6 +47,10 @@ ], "flee_chance": 0.3, "status_inflict_chance": 0.15, + "skills": [ + "rabid_bite", + "howl" + ], "image_path": "images/npcs/feral_dog.webp", "death_message": { "en": "The feral dog whimpers and collapses. Perhaps it was just hungry...", @@ -112,6 +116,10 @@ ], "flee_chance": 0.2, "status_inflict_chance": 0.1, + "skills": [ + "bandage_self", + "quick_slash" + ], "image_path": "images/npcs/raider_scout.webp", "death_message": { "en": "The raider scout falls with a final gasp. Their supplies are yours.", @@ -159,6 +167,9 @@ ], "flee_chance": 0.5, "status_inflict_chance": 0.25, + "skills": [ + "rabid_bite" + ], "image_path": "images/npcs/mutant_rat.webp", "death_message": { "en": "The mutant rat squeals its last and goes still.", @@ -212,6 +223,10 @@ ], "flee_chance": 0.1, "status_inflict_chance": 0.3, + "skills": [ + "rabid_bite", + "power_strike" + ], "image_path": "images/npcs/infected_human.webp", "death_message": { "en": "The infected human finally finds peace in death.", @@ -289,6 +304,10 @@ ], "flee_chance": 0.25, "status_inflict_chance": 0.05, + "skills": [ + "bandage_self", + "power_strike" + ], "image_path": "images/npcs/scavenger.webp", "death_message": { "en": "The scavenger's struggle ends. Survival has no mercy.", @@ -305,6 +324,7 @@ "en": "A huge terrifying monster.", "es": "Un monstruo enorme y aterrador." }, + "image_path": "images/npcs/test_boss.webp", "emoji": "👹", "hp_min": 1000, "hp_max": 2000, @@ -315,6 +335,11 @@ "loot_table": [], "flee_chance": 0.0, "status_inflict_chance": 0.5, + "skills": [ + "howl", + "power_strike", + "crushing_blow" + ], "death_message": { "en": "The boss is defeated.", "es": "El jefe ha sido derrotado." diff --git a/gamedata/perks.json b/gamedata/perks.json index ee091a0..aeb1f91 100644 --- a/gamedata/perks.json +++ b/gamedata/perks.json @@ -91,8 +91,8 @@ "es": "Resistente" }, "description": { - "en": "Status effects last 1 fewer turn (min 1)", - "es": "Los efectos de estado duran 1 turno menos (mín 1)" + "en": "Status effects last 1 fewer {{interval}} (min 1)", + "es": "Los efectos de estado duran 1 {{interval}} menos (mín 1)" }, "icon": "💪", "requirements": { diff --git a/gamedata/skills.json b/gamedata/skills.json index 88292ab..3e094ff 100644 --- a/gamedata/skills.json +++ b/gamedata/skills.json @@ -47,8 +47,8 @@ "es": "Furia Berserker" }, "description": { - "en": "+50% damage for 3 turns, but +25% damage taken", - "es": "+50% de daño durante 3 turnos, pero +25% de daño recibido" + "en": "+50% damage for 3 {{intervals_plural}}, but +25% damage taken", + "es": "+50% de daño durante 3 {{intervals_plural}}, pero +25% de daño recibido" }, "icon": "🔥", "stat_requirement": "strength", @@ -131,8 +131,8 @@ "es": "Hoja Envenenada" }, "description": { - "en": "80% damage + poison (3 dmg/turn for 4 turns)", - "es": "80% de daño + veneno (3 de daño/turno durante 4 turnos)" + "en": "80% damage + poison (5% max HP/{{interval}} for 4 {{intervals_plural}})", + "es": "80% de daño + veneno (5% vida máx/{{interval}} durante 4 {{intervals_plural}})" }, "icon": "🧪", "stat_requirement": "agility", @@ -142,7 +142,7 @@ "stamina_cost": 6, "effects": { "damage_multiplier": 0.8, - "poison_damage": 3, + "poison_percent": 0.05, "poison_duration": 4 } }, @@ -172,8 +172,8 @@ "es": "Fortificar" }, "description": { - "en": "Reduce incoming damage by 60% for 2 turns", - "es": "Reduce el daño recibido en un 60% durante 2 turnos" + "en": "Reduce incoming damage by 60% for 2 {{intervals_plural}}", + "es": "Reduce el daño recibido en un 60% durante 2 {{intervals_plural}}" }, "icon": "🛡️", "stat_requirement": "endurance", @@ -212,8 +212,8 @@ "es": "Piel de Hierro" }, "description": { - "en": "Immune to status effects for 3 turns", - "es": "Inmune a efectos de estado durante 3 turnos" + "en": "Immune to status effects for 3 {{intervals_plural}}", + "es": "Inmune a efectos de estado durante 3 {{intervals_plural}}" }, "icon": "🪨", "stat_requirement": "endurance", @@ -327,6 +327,67 @@ "buff_duration": 2, "enemy_miss": true } + }, + "rabid_bite": { + "name": { + "en": "Rabid Bite", + "es": "Mordedura Rabiosa" + }, + "description": { + "en": "A vicious bite that can infect the target with poison", + "es": "Una mordedura feroz que puede infectar al objetivo con veneno" + }, + "icon": "🦷", + "stat_requirement": "agility", + "stat_threshold": 0, + "level_requirement": 1, + "cooldown": 4, + "stamina_cost": 0, + "effects": { + "damage_multiplier": 1.2, + "poison_percent": 0.04, + "poison_duration": 3 + } + }, + "howl": { + "name": { + "en": "Howl", + "es": "Aullido" + }, + "description": { + "en": "Increases damage by 50% for 3 {{intervals_plural}}", + "es": "Aumenta el daño en un 50% durante 3 {{intervals_plural}}" + }, + "icon": "🐺", + "stat_requirement": "strength", + "stat_threshold": 0, + "level_requirement": 1, + "cooldown": 8, + "stamina_cost": 0, + "effects": { + "buff": "berserker_rage", + "buff_duration": 3, + "damage_bonus": 0.5 + } + }, + "bandage_self": { + "name": { + "en": "Bandage Self", + "es": "Vendarse" + }, + "description": { + "en": "Restores 25% of maximum HP", + "es": "Restaura el 25% de la vida máxima" + }, + "icon": "🩹", + "stat_requirement": "intellect", + "stat_threshold": 0, + "level_requirement": 1, + "cooldown": 6, + "stamina_cost": 0, + "effects": { + "heal_percent": 0.25 + } } } } \ No newline at end of file diff --git a/gamedata/status_effects.json b/gamedata/status_effects.json new file mode 100644 index 0000000..4fa856e --- /dev/null +++ b/gamedata/status_effects.json @@ -0,0 +1,76 @@ +{ + "effects": { + "poison": { + "icon": "🧪", + "name": { + "en": "Poison", + "es": "Veneno" + }, + "description": { + "en": "Deals damage each {{interval}}", + "es": "Inflige daño cada {{interval}}" + }, + "type": "damage" + }, + "stun": { + "icon": "💫", + "name": { + "en": "Stunned", + "es": "Aturdido" + }, + "description": { + "en": "Cannot act this {{interval}}", + "es": "No puede actuar este {{interval}}" + }, + "type": "debuff" + }, + "analyzed": { + "icon": "🔍", + "name": { + "en": "Analyzed", + "es": "Analizado" + }, + "description": { + "en": "Weakness exposed, vulnerable to Exploit Weakness", + "es": "Debilidad expuesta, vulnerable a Explotar Debilidad" + }, + "type": "debuff" + }, + "bleeding": { + "icon": "🩸", + "name": { + "en": "Bleeding", + "es": "Sangrado" + }, + "description": { + "en": "Losing blood each {{interval}}", + "es": "Pierde sangre cada {{interval}}" + }, + "type": "damage" + }, + "burning": { + "icon": "🔥", + "name": { + "en": "Burning", + "es": "Ardiendo" + }, + "description": { + "en": "Takes fire damage each {{interval}}", + "es": "Recibe daño de fuego cada {{interval}}" + }, + "type": "damage" + }, + "regeneration": { + "icon": "💚", + "name": { + "en": "Regeneration", + "es": "Regeneración" + }, + "description": { + "en": "Recovers HP every {{interval}}", + "es": "Recupera PS cada {{interval}}" + }, + "type": "buff" + } + } +} \ No newline at end of file diff --git a/images-source/npcs/test_boss.jpeg b/images-source/npcs/test_boss.jpeg new file mode 100644 index 0000000..c5ee719 Binary files /dev/null and b/images-source/npcs/test_boss.jpeg differ diff --git a/images/npcs/test_boss.webp b/images/npcs/test_boss.webp new file mode 100644 index 0000000..0811cc6 Binary files /dev/null and b/images/npcs/test_boss.webp differ diff --git a/pwa/src/components/Game.tsx b/pwa/src/components/Game.tsx index a714b5c..28d2d82 100644 --- a/pwa/src/components/Game.tsx +++ b/pwa/src/components/Game.tsx @@ -424,6 +424,9 @@ function Game() { try { const response = await api.post('/api/game/pvp/action', { action }) actions.setMessage(response.data.message || 'Action performed!') + if (response.data.equipment) { + actions.updateEquipment(response.data.equipment) + } // We don't need to fetchGameData here because the websocket update will handle it? // The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call." // So we should probably update state from response if possible, OR fetch. @@ -504,6 +507,8 @@ function Game() { onUncraft={(uniqueItemId: string, inventoryId: number, quantity?: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId, quantity)} failedActionItemId={state.failedActionItemId} quests={state.quests} + craftedItemResult={state.craftedItemResult} + onCloseCraftedItemResult={() => actions.setCraftedItemResult(null)} /> )} diff --git a/pwa/src/components/common/ItemStatBadges.tsx b/pwa/src/components/common/ItemStatBadges.tsx index d257f50..ade08a5 100644 --- a/pwa/src/components/common/ItemStatBadges.tsx +++ b/pwa/src/components/common/ItemStatBadges.tsx @@ -82,14 +82,14 @@ export const ItemStatBadges = ({ item }: ItemStatBadgesProps) => { 🏋️ +{stats.endurance_bonus} {t('stats.end')} )} - {(stats.hp_bonus) && ( + {(stats.max_hp) && ( - ❤️ +{stats.hp_bonus} {t('stats.hpMax')} + ❤️ +{stats.max_hp} {t('stats.hpMax')} )} - {(stats.stamina_bonus) && ( + {(stats.max_stamina) && ( - ⚡ +{stats.stamina_bonus} {t('stats.stmMax')} + ⚡ +{stats.max_stamina} {t('stats.stmMax')} )} diff --git a/pwa/src/components/game/CharacterSheet.tsx b/pwa/src/components/game/CharacterSheet.tsx index b78b727..baf65ab 100644 --- a/pwa/src/components/game/CharacterSheet.tsx +++ b/pwa/src/components/game/CharacterSheet.tsx @@ -5,6 +5,8 @@ import api from '../../services/api'; import { GameModal } from './GameModal'; import { GameProgressBar } from '../common/GameProgressBar'; import { GameButton } from '../common/GameButton'; +import { GameTooltip } from '../common/GameTooltip'; +import { EffectBadge } from './EffectBadge'; import './CharacterSheet.css'; interface CharacterSheetProps { @@ -75,6 +77,7 @@ interface CharacterSheetData { used_points: number; all_perks: PerkData[]; }; + status_effects: any[]; character: { name: string; level: number; @@ -149,7 +152,7 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) { ); } - const { base_stats, derived_stats, skills, perks, character } = data; + const { base_stats, derived_stats, skills, perks, character, status_effects } = data; const renderStatsTab = () => (
@@ -186,6 +189,27 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) { />
+ {status_effects && status_effects.length > 0 && ( +
+
{t('characterSheet.activeEffects', 'Active Effects')}
+
+ {status_effects.map((e: any) => ( + +
+ 0 ? 'damage' : 'buff'), + damage_per_tick: e.damage_per_tick, + ticks: e.ticks_remaining + }} /> +
+
+ ))} +
+
+ )} + {base_stats.unspent_points > 0 && (
{base_stats.unspent_points} {t('characterSheet.pointsAvailable', 'points available')} @@ -273,7 +297,7 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) { 🔒 )}
-

{getTranslatedText(skill.description)}

+

{getTranslatedText(skill.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}

{skill.stamina_cost} 🔄 {skill.cooldown}t @@ -313,7 +337,7 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) { {perk.icon}
{getTranslatedText(perk.name)} -

{getTranslatedText(perk.description)}

+

{getTranslatedText(perk.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}

{perk.owned ? ( {t('characterSheet.owned', 'Owned')} diff --git a/pwa/src/components/game/Combat.tsx b/pwa/src/components/game/Combat.tsx index 237b8dd..ed1494f 100644 --- a/pwa/src/components/game/Combat.tsx +++ b/pwa/src/components/game/Combat.tsx @@ -132,7 +132,10 @@ export const Combat: React.FC = ({ opponentName: isPvP ? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username) : undefined, - turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining + turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining, + npcEffects: initialCombatData?.combat?.npc_effects || [], + playerEffects: initialCombatData?.player_effects || [], + npcIntent: initialCombatData?.combat?.npc_intent }); const [animState, setAnimState] = useState({ @@ -158,6 +161,8 @@ export const Combat: React.FC = ({ const pendingPlayerHpRef = useRef<{ hp: number; max_hp: number } | null>(null); // Store server player XP to apply when XP floating text appears const pendingPlayerXpRef = useRef<{ xp: number; level: number } | null>(null); + // Store server equipment to apply when attack/hit animations occur + const pendingEquipmentRef = useRef(null); // Update queueRef useEffect(() => { @@ -284,6 +289,7 @@ export const Combat: React.FC = ({ yourTurn: newYourTurn !== undefined ? newYourTurn : prev.yourTurn, round: initialCombatData?.combat?.round ?? prev.round, turnTimeRemaining: newTimeRemaining !== undefined ? newTimeRemaining : prev.turnTimeRemaining, + npcIntent: initialCombatData?.combat?.npc_intent ?? prev.npcIntent, // Sync HP for PVP from WebSocket updates ...(isPvP && newPlayerHp !== undefined ? { playerHp: newPlayerHp } : {}), ...(isPvP && newNpcHp !== undefined ? { npcHp: newNpcHp } : {}) @@ -411,14 +417,17 @@ export const Combat: React.FC = ({ // Apply server player HP when floating text appears if (pendingPlayerHpRef.current) { const { hp, max_hp } = pendingPlayerHpRef.current; - setLocalCombatState(prev => ({ - ...prev, - playerHp: hp, - playerMaxHp: max_hp - })); + setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp })); updatePlayerState({ hp, max_hp }); pendingPlayerHpRef.current = null; } + + // Apply pending equipment update (durability loss from being hit) + if (pendingEquipmentRef.current) { + updatePlayerState({ equipment: pendingEquipmentRef.current }); + pendingEquipmentRef.current = null; + } + } break; @@ -436,6 +445,18 @@ export const Combat: React.FC = ({ triggerAnim('shaking', 500); if (data.damage) { addFloatingText(`-${data.damage}!`, 'crit', 'player'); + + if (pendingPlayerHpRef.current) { + const { hp, max_hp } = pendingPlayerHpRef.current; + setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp })); + updatePlayerState({ hp, max_hp }); + pendingPlayerHpRef.current = null; + } + + if (pendingEquipmentRef.current) { + updatePlayerState({ equipment: pendingEquipmentRef.current }); + pendingEquipmentRef.current = null; + } } break; @@ -502,13 +523,28 @@ export const Combat: React.FC = ({ // ── Skill messages ── case 'skill_attack': - triggerAnim('playerAttacking'); - triggerAnim('npcHit', 300); + const target_origin = origin === 'enemy' ? 'player' : 'enemy'; + triggerAnim(origin === 'enemy' ? 'enemyAttacking' : 'playerAttacking'); + triggerAnim(origin === 'enemy' ? 'playerHit' : 'npcHit', 300); + if (data.damage) { const label = data.hits > 1 ? `${data.skill_icon || '⚔️'} -${data.damage} (x${data.hits})` : `${data.skill_icon || '⚔️'} -${data.damage}`; - addFloatingText(label, 'damage', 'enemy'); + addFloatingText(label, 'damage', target_origin); + + if (target_origin === 'player') { + if (pendingPlayerHpRef.current) { + const { hp, max_hp } = pendingPlayerHpRef.current; + setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp })); + updatePlayerState({ hp, max_hp }); + pendingPlayerHpRef.current = null; + } + if (pendingEquipmentRef.current) { + updatePlayerState({ equipment: pendingEquipmentRef.current }); + pendingEquipmentRef.current = null; + } + } } break; @@ -572,6 +608,12 @@ export const Combat: React.FC = ({ } else if (messageQueue.length === 0 && isProcessingQueue) { // Queue just finished processing setIsProcessingQueue(false); + + // Apply pending equipment updates (durability loss etc.) after ALL animations finish + if (pendingEquipmentRef.current) { + updatePlayerState({ equipment: pendingEquipmentRef.current }); + pendingEquipmentRef.current = null; + } } }, [messageQueue, processQueue, isProcessingQueue]); @@ -596,13 +638,23 @@ export const Combat: React.FC = ({ npcMaxHp: data.combat.npc_max_hp, turn: data.combat.turn, round: data.combat.round, - npcName: resolveName(data.combat.npc_name) || prev.npcName + npcName: resolveName(data.combat.npc_name) || prev.npcName, + npcEffects: data.combat.npc_effects || [], + playerEffects: (data as any).player_effects || [], + npcIntent: data.combat.npc_intent })); - } else if (data.combat_over && data.player_won) { + } else if (data.combat_over && data.player_won === true && action !== 'flee') { + // Apply any remaining pending data on victory + if (pendingEquipmentRef.current) { + updatePlayerState({ equipment: pendingEquipmentRef.current }); + pendingEquipmentRef.current = null; + } // Combat ended with victory but data.combat is null - set enemy HP to 0 setLocalCombatState(prev => ({ ...prev, - npcHp: 0 + npcHp: 0, + npcEffects: [], + playerEffects: [] })); } @@ -611,8 +663,13 @@ export const Combat: React.FC = ({ pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp }; // Store player XP to apply when xp_gain message is processed pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level }; - refreshCharacters(); } + + if (data.equipment) { + pendingEquipmentRef.current = data.equipment; + } + + refreshCharacters(); } } catch (err) { console.error(err); @@ -753,12 +810,17 @@ export const Combat: React.FC = ({ npcMaxHp: data.combat.npc_max_hp, turn: data.combat.turn, round: data.combat.round, - npcName: resolveName(data.combat.npc_name) || prev.npcName + npcName: resolveName(data.combat.npc_name) || prev.npcName, + npcEffects: data.combat.npc_effects || [], + playerEffects: (data as any).player_effects || [], + npcIntent: data.combat.npc_intent })); - } else if (data.combat_over && data.player_won) { + } else if (data.combat_over && data.player_won === true) { setLocalCombatState(prev => ({ ...prev, - npcHp: 0 + npcHp: 0, + npcEffects: [], + playerEffects: [] })); } diff --git a/pwa/src/components/game/CombatEffects.css b/pwa/src/components/game/CombatEffects.css index 0aab3f7..443a111 100644 --- a/pwa/src/components/game/CombatEffects.css +++ b/pwa/src/components/game/CombatEffects.css @@ -33,7 +33,7 @@ clip-path: var(--game-clip-path); border: 1px solid rgba(255, 107, 107, 0.3); flex-shrink: 0; -} +} .combat-location-bg { width: 100%; @@ -557,4 +557,36 @@ .progress-fill { height: 100%; transition: width 0.3s ease-out; +} + +/* Combat Status Effect Badges */ +.combat-effects-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.combat-effect-badge { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 1px 6px; + font-size: 0.7rem; + font-weight: 600; + line-height: 1.3; + white-space: nowrap; + clip-path: var(--game-clip-path-sm); +} + +.combat-effect-badge.effect-buff { + background: rgba(76, 175, 80, 0.3); + border: 1px solid rgba(76, 175, 80, 0.5); + color: #81c784; +} + +.combat-effect-badge.effect-debuff { + background: rgba(220, 53, 69, 0.3); + border: 1px solid rgba(220, 53, 69, 0.5); + color: #ef9a9a; } \ No newline at end of file diff --git a/pwa/src/components/game/CombatTypes.ts b/pwa/src/components/game/CombatTypes.ts index 8822901..f3d6cc2 100644 --- a/pwa/src/components/game/CombatTypes.ts +++ b/pwa/src/components/game/CombatTypes.ts @@ -15,6 +15,14 @@ export interface FloatingText { timestamp: number; } +export interface CombatEffect { + name: string | Record; + icon: string; + ticks_remaining: number; + type?: string; // 'buff', 'debuff', 'damage' + description?: string | Record; +} + export interface CombatState { inCombat: boolean; turn: 'player' | 'enemy' | 'attacker' | 'defender'; @@ -31,6 +39,9 @@ export interface CombatState { round: number; isPvP?: boolean; opponentName?: string; + npcEffects?: CombatEffect[]; + playerEffects?: CombatEffect[]; + npcIntent?: string; } export interface CombatActionResponse { @@ -47,6 +58,7 @@ export interface CombatActionResponse { level: number; }; winner_id?: string; + equipment?: any; } export interface AnimationState { diff --git a/pwa/src/components/game/CombatView.tsx b/pwa/src/components/game/CombatView.tsx index 75977e1..025cbdb 100644 --- a/pwa/src/components/game/CombatView.tsx +++ b/pwa/src/components/game/CombatView.tsx @@ -8,6 +8,7 @@ import './CombatEffects.css'; import { GameProgressBar } from '../common/GameProgressBar'; import { GameButton } from '../common/GameButton'; import { GameDropdown } from '../common/GameDropdown'; +import { GameTooltip } from '../common/GameTooltip'; import api from '../../services/api'; interface CombatViewProps { @@ -122,6 +123,20 @@ export const CombatView: React.FC = ({ } }, [state.messages]); + const getIntentDisplay = (intent: string) => { + switch (intent) { + case 'defend': return { icon: '🛡️', text: t('combat.intents.defend', 'Defending') }; + case 'flee': return { icon: '🏃', text: t('combat.intents.flee', 'Fleeing') }; + case 'buff': return { icon: '✨', text: t('combat.intents.buff', 'Buffing') }; + case 'attack': return { icon: '⚔️', text: t('combat.intents.attack', 'Attacking') }; + case 'charging_attack': return { icon: '⚠️', text: t('combat.intents.charging', 'Charging Attack!') }; + default: + // For skills like bandage_self etc. + const skillName = intent.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + return { icon: '🌀', text: t(`combat.intents.${intent}`, skillName) }; + } + }; + return (
@@ -231,6 +246,31 @@ export const CombatView: React.FC = ({ height="10px" labelAlignment="right" /> + {/* Enemy Intent */} + {!state.isPvP && state.npcIntent && !combatResult && ( +
+ {getIntentDisplay(state.npcIntent).icon} + {t('combat.intents.label', 'Next move:')} {getIntentDisplay(state.npcIntent).text} +
+ )} + {/* Enemy Status Effects */} + {state.npcEffects && state.npcEffects.length > 0 && ( +
+ {state.npcEffects.map((eff, i) => ( + +
{eff.icon} {getTranslatedText(eff.name)}
+
{getTranslatedText(eff.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}
+
{t('combat.log.turns_remaining', { turns: eff.ticks_remaining })}
+
+ }> + + {eff.icon} {eff.ticks_remaining} + + + ))} +
+ )}
{/* Player HP (Right) */} @@ -245,6 +285,24 @@ export const CombatView: React.FC = ({ align="right" labelAlignment="left" /> + {/* Player Active Buffs/Effects */} + {state.playerEffects && state.playerEffects.length > 0 && ( +
+ {state.playerEffects.map((eff, i) => ( + +
{eff.icon} {getTranslatedText(eff.name)}
+
{getTranslatedText(eff.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}
+
{t('combat.log.turns_remaining', { turns: eff.ticks_remaining })}
+
+ }> + + {eff.icon} {eff.ticks_remaining} + + + ))} + + )} @@ -261,17 +319,27 @@ export const CombatView: React.FC = ({ )} {!combatResult && ( -
+
onAction('attack')} disabled={isProcessing || !state.yourTurn} > 👊 {t('combat.actions.attack')} + onAction('defend')} + disabled={isProcessing || !state.yourTurn} + > + 🛡️ {t('combat.actions.defend')} + + @@ -325,10 +393,13 @@ export const CombatView: React.FC = ({ case 'enemy_miss': text = t('combat.log.enemy_miss'); break; case 'victory': text = t('combat.victory'); className += " text-success bold"; break; case 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break; - case 'flee_success': text = t('combat.flee.success'); break; - case 'flee_fail': text = t('combat.flee.fail'); break; - case 'item_broken': text = t('combat.item_broken', { item: getTranslatedText(msg.data?.item_name) }); break; - case 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break; + case 'flee_success': text = t('combat.log.flee_success'); break; + case 'flee_fail': + text = t('combat.log.flee_fail'); + className += " text-danger"; + break; + case 'item_broken': text = t('combat.log.item_broken', { item: getTranslatedText(msg.data?.item_name), emoji: msg.data?.emoji || '' }); break; + case 'xp_gain': text = t('combat.log.xp_gain', { amount: msg.data?.amount }); className += " text-warning"; break; case 'damage': if (msg.origin === 'enemy') { text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 }); @@ -340,7 +411,7 @@ export const CombatView: React.FC = ({ case 'text': text = getTranslatedText(msg.data?.text) || ""; break; case 'item_used': text = t('combat.log.item_used', { item: getTranslatedText(msg.data?.item_name) || '' }); - if (msg.data?.effects) text += getTranslatedText(msg.data.effects); // Append effects string if backend still sends it + if (msg.data?.effects) text += getTranslatedText(msg.data.effects); className += " text-info"; break; case 'effect_applied': @@ -350,7 +421,121 @@ export const CombatView: React.FC = ({ }); className += " text-warning"; break; - default: text = msg.type; + // ── Skill messages ── + case 'skill_attack': { + const hitsText = msg.data?.hits > 1 ? ` (x${msg.data.hits})` : ''; + text = t('combat.log.skill_attack', { + skill_icon: msg.data?.skill_icon || '⚔️', + skill_name: getTranslatedText(msg.data?.skill_name) || '', + damage: msg.data?.damage || 0, + hits_text: hitsText + }); + break; + } + case 'skill_heal': + text = t('combat.log.skill_heal', { + skill_icon: msg.data?.skill_icon || '💚', + skill_name: getTranslatedText(msg.data?.skill_name) || '', + heal: msg.data?.heal || 0 + }); + className += " text-success"; + break; + case 'skill_buff': + text = t('combat.log.skill_buff', { + skill_icon: msg.data?.skill_icon || '🛡️', + skill_name: getTranslatedText(msg.data?.skill_name) || '' + }); + className += " text-info"; + break; + case 'skill_effect': + text = msg.data?.message || ''; + className += " text-info"; + break; + case 'skill_analyze': + text = t('combat.log.skill_analyze', { skill_icon: msg.data?.skill_icon || '🔍' }); + className += " text-info"; + break; + // ── Combat reactions ── + case 'combat_crit': + text = t('combat.log.combat_crit'); + className += " text-warning bold"; + break; + case 'combat_dodge': + text = t('combat.log.combat_dodge'); + className += " text-success"; + break; + case 'combat_block': + text = t('combat.log.combat_block'); + className += " text-success"; + break; + case 'damage_reduced': + text = t('combat.log.damage_reduced', { reduction: msg.data?.reduction || 0 }); + className += " text-info"; + break; + case 'player_defend': + text = t('combat.log.defend'); + className += " text-info bold"; + break; + // ── Enemy actions ── + case 'enemy_enraged': + text = t('combat.log.enemy_enraged', { npc_name: getTranslatedText(msg.data?.npc_name) || t('common.enemy') }); + className += " text-danger bold"; + break; + case 'enemy_defend': + text = t('combat.log.enemy_defend', { heal: msg.data?.heal || 0 }); + className += " text-danger"; + break; + case 'enemy_special': + text = t('combat.log.enemy_special', { damage: msg.data?.damage || 0 }); + className += " text-danger bold"; + break; + // ── Status effects ── + case 'effect_damage': + if (msg.origin === 'enemy') { + text = t('combat.log.effect_damage_npc', { damage: msg.data?.damage || 0 }); + } else { + text = t('combat.log.effect_damage', { damage: msg.data?.damage || 0 }); + } + className += " text-danger"; + break; + case 'effect_bleeding': + text = t('combat.log.effect_bleeding', { damage: msg.data?.damage || 0 }); + className += " text-danger"; + break; + case 'effect_heal': + text = t('combat.log.effect_heal', { heal: msg.data?.heal || 0 }); + className += " text-success"; + break; + // ── Items ── + case 'weapon_broke': + text = t('combat.log.weapon_broke', { item_name: getTranslatedText(msg.data?.item_name) || '' }); + className += " text-danger"; + break; + case 'item_heal': + text = t('combat.log.item_heal', { heal: msg.data?.heal || 0 }); + className += " text-success"; + break; + case 'item_restore': + text = t('combat.log.item_restore', { amount: msg.data?.amount || 0, stat: msg.data?.stat || '' }); + className += " text-info"; + break; + case 'item_damage': + text = t('combat.log.item_damage', { item: getTranslatedText(msg.data?.item_name) || '', damage: msg.data?.damage || 0 }); + break; + // ── Outcomes ── + case 'level_up': + text = t('combat.log.level_up', { new_level: msg.data?.new_level || 0 }); + className += " text-warning bold"; + break; + case 'died': + text = t('combat.log.died'); + className += " text-danger bold"; + break; + case 'quest_update': + text = msg.data?.message || ''; + className += " text-info"; + break; + default: text = msg.data?.message || msg.type; } } const time = msg.timestamp || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); @@ -385,6 +570,7 @@ export const CombatView: React.FC = ({ interface SkillInfo { id: string; name: any; + description: any; icon: string; stamina_cost: number; cooldown: number; @@ -429,7 +615,7 @@ const AbilitiesDropdown: React.FC<{ disabled={disabled} style={{ width: '100%' }} > - ⚔️ {t('combat.actions.abilities', 'Abilities')} + ⚔️ {t('combat.actions.abilities')} {open && skills.length > 0 && ( handleUse(s.id)} - disabled={isSkillDisabled} - style={{ width: '100%', justifyContent: 'flex-start', marginBottom: '4px' }} - > -
- {s.icon} - {getTranslatedText(s.name)} - {onCooldown ? ( - ⏳ {s.current_cooldown}T - ) : ( - ⚡{s.stamina_cost} - )} + +
{s.icon} {getTranslatedText(s.name)}
+
{getTranslatedText(s.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}
+
+ ⚡ {s.stamina_cost} {t('combat.stamina', 'Stamina')} + ⏳ {t('combat.cooldown_turns', { turns: s.cooldown })} +
- + }> + handleUse(s.id)} + disabled={isSkillDisabled} + style={{ width: '100%', justifyContent: 'flex-start', marginBottom: '4px' }} + > +
+ {s.icon} + {getTranslatedText(s.name)} + {onCooldown ? ( + ⏳ {s.current_cooldown} + ) : ( + ⚡{s.stamina_cost} + )} +
+
+ ); })}
diff --git a/pwa/src/components/game/EffectBadge.tsx b/pwa/src/components/game/EffectBadge.tsx index b341bf6..d7d64cf 100644 --- a/pwa/src/components/game/EffectBadge.tsx +++ b/pwa/src/components/game/EffectBadge.tsx @@ -24,7 +24,7 @@ export const EffectBadge: React.FC = ({ effect }) => { : getTranslatedText(effect.name); return ( - + {effect.icon} {effect.damage_per_tick ? ( <> diff --git a/pwa/src/components/game/LocationView.tsx b/pwa/src/components/game/LocationView.tsx index f10e347..c232a0d 100644 --- a/pwa/src/components/game/LocationView.tsx +++ b/pwa/src/components/game/LocationView.tsx @@ -68,6 +68,8 @@ interface LocationViewProps { onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void failedActionItemId: string | number | null quests: { active: any[], available: any[] } + craftedItemResult: any | null + onCloseCraftedItemResult: () => void } function LocationView({ @@ -90,6 +92,8 @@ function LocationView({ craftCategoryFilter, profile, quests, + craftedItemResult, + onCloseCraftedItemResult, onInitiateCombat, onInitiatePvP, @@ -810,6 +814,8 @@ function LocationView({ onCraft={onCraft} onRepair={onRepair} onUncraft={onUncraft} + craftedItemResult={craftedItemResult} + onCloseCraftedItemResult={onCloseCraftedItemResult} /> ) } diff --git a/pwa/src/components/game/PlayerSidebar.tsx b/pwa/src/components/game/PlayerSidebar.tsx index 318a053..cefc02a 100644 --- a/pwa/src/components/game/PlayerSidebar.tsx +++ b/pwa/src/components/game/PlayerSidebar.tsx @@ -11,6 +11,7 @@ import { GameButton } from '../common/GameButton' import { GameItemCard } from '../common/GameItemCard' import { GameDropdown } from '../common/GameDropdown' import { useAudio } from '../../contexts/AudioContext' +import { EffectBadge } from './EffectBadge' interface PlayerSidebarProps { playerState: PlayerState @@ -140,14 +141,18 @@ function PlayerSidebar({
{t('stats.hp')}
- {playerState.status_effects?.filter((e: any) => e.damage_per_tick !== 0).map((e: any) => ( - 0 ? 'negative' : 'positive'}`} style={{ - color: e.damage_per_tick > 0 ? '#ff6b6b' : '#4caf50', - fontSize: '0.85rem', - fontWeight: 'bold' - }}> - {e.damage_per_tick > 0 ? `-${e.damage_per_tick}` : `+${Math.abs(e.damage_per_tick)}`}/t ({e.ticks_remaining}) - + {playerState.status_effects?.map((e: any) => ( + +
+ 0 ? 'damage' : 'buff'), + damage_per_tick: e.damage_per_tick, + ticks: e.ticks_remaining + }} /> +
+
))}
diff --git a/pwa/src/components/game/Workbench.tsx b/pwa/src/components/game/Workbench.tsx index c613571..1f10e82 100644 --- a/pwa/src/components/game/Workbench.tsx +++ b/pwa/src/components/game/Workbench.tsx @@ -28,6 +28,8 @@ interface WorkbenchProps { onCraft: (itemId: number) => void onRepair: (uniqueItemId: string, inventoryId: number) => void onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void + craftedItemResult: any | null + onCloseCraftedItemResult: () => void } function Workbench({ @@ -50,12 +52,15 @@ function Workbench({ onSetCraftCategoryFilter, onCraft, onRepair, - onUncraft + onUncraft, + craftedItemResult, + onCloseCraftedItemResult }: WorkbenchProps) { const { t } = useTranslation() const [selectedItem, setSelectedItem] = useState(null) const [salvageQuantity, setSalvageQuantity] = useState(1) + const [showSalvageModal, setShowSalvageModal] = useState(false) // Reset selection when tab changes useEffect(() => { @@ -448,10 +453,7 @@ function Workbench({ variant="danger" disabled={(profile?.stamina || 0) < ((item.stamina_cost || 1) * salvageQuantity)} onClick={() => { - const confirmMsg = t('crafting.confirmSalvage', { name: getTranslatedText(item.name) }) - if (window.confirm(`${confirmMsg} (x${salvageQuantity})`)) { - onUncraft(item.unique_item_id, item.inventory_id, salvageQuantity) - } + setShowSalvageModal(true) }} style={{ width: '100%' }} > @@ -677,6 +679,99 @@ function Workbench({
+ + {showSalvageModal && selectedItem && ( + setShowSalvageModal(false)} + className="salvage-confirm-modal" + > +
+

{t('crafting.confirmSalvage', { name: getTranslatedText(selectedItem.name) })} (x{salvageQuantity})

+
+ setShowSalvageModal(false)}> + {t('common.cancel', 'Cancel')} + + { + onUncraft(selectedItem.unique_item_id, selectedItem.inventory_id, salvageQuantity) + setShowSalvageModal(false) + }}> + {t('common.confirm', 'Confirm')} + +
+
+
+ )} + + {/* Crafted Item Feedback Modal */} + {craftedItemResult && ( + +
+
+ {craftedItemResult.image_path ? ( + {getTranslatedText(craftedItemResult.name)} { + (e.target as HTMLImageElement).style.display = 'none'; + const icon = (e.target as HTMLImageElement).nextElementSibling; + if (icon) icon.classList.remove('hidden'); + }} + /> + ) : null} +
+ {craftedItemResult.emoji || '📦'} +
+
+ +

+ {getTranslatedText(craftedItemResult.name)} +

+ + {craftedItemResult.tier && ( + + Tier {craftedItemResult.tier} + + )} + +
+ {Object.entries(craftedItemResult.unique_item_data?.unique_stats ?? craftedItemResult.unique_item_data ?? craftedItemResult.base_stats ?? craftedItemResult.stats ?? {}) + .filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k)) + .map(([key, value]) => { + const icons: Record = { + weight_capacity: `⚖️ ${t('game.weight')}`, + volume_capacity: `📦 ${t('game.volume')}`, + armor: `🛡️ ${t('stats.armor')}`, + hp_max: `❤️ ${t('stats.maxHp')}`, + stamina_max: `⚡ ${t('stats.maxStamina')}`, + damage_min: `⚔️ ${t('stats.damage')} Min`, + damage_max: `⚔️ ${t('stats.damage')} Max` + } + const label = icons[key] || key.replace('_', ' ') + const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : '' + return ( +
+ {label}: +{Math.round(Number(value))}{unit} +
+ ) + })} +
+ +
+ + {t('common.continue', 'Continue')} + +
+
+
+ )} + ) } diff --git a/pwa/src/components/game/hooks/useGameEngine.ts b/pwa/src/components/game/hooks/useGameEngine.ts index 3be23df..e85031e 100644 --- a/pwa/src/components/game/hooks/useGameEngine.ts +++ b/pwa/src/components/game/hooks/useGameEngine.ts @@ -54,6 +54,7 @@ export interface GameEngineState { uncraftFilter: string inventoryFilter: string inventoryCategoryFilter: string + craftedItemResult: any | null // PvP state lastSeenPvPAction: string | null @@ -130,6 +131,7 @@ export interface GameEngineActions { setInventoryFilter: (filter: string) => void setInventoryCategoryFilter: (filter: string) => void toggleCategoryCollapse: (category: string) => void + setCraftedItemResult: (result: any) => void // WebSocket helpers refreshLocation: () => Promise @@ -142,6 +144,7 @@ export interface GameEngineActions { addNPCToLocation: (npc: any) => void removeNPCFromLocation: (enemyId: string) => void updateStatusEffect: (effectName: string | any, remainingTicks: number) => void + updateEquipment: (equipmentData: any) => void // Quests updateQuests: (active: any[], available: any[]) => void @@ -186,6 +189,7 @@ export function useGameEngine( const [uncraftableItems, setUncraftableItems] = useState([]) const [inventoryFilter, setInventoryFilter] = useState('') const [inventoryCategoryFilter, setInventoryCategoryFilter] = useState('all') + const [craftedItemResult, setCraftedItemResult] = useState(null) const [lastSeenPvPAction, setLastSeenPvPAction] = useState(null) const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState(null) const [mobileMenuOpen, setMobileMenuOpen] = useState('none') @@ -483,7 +487,8 @@ export function useGameEngine( in_combat: true, combat_over: false, player_won: false, - combat: encounter.combat + combat: encounter.combat, + player_effects: encounter.player_effects || [] }) setCombatLog([]) @@ -663,7 +668,8 @@ export function useGameEngine( mobileHeaderOpen, locationMessages, interactableCooldowns, - forceUpdate: _forceUpdate + forceUpdate: _forceUpdate, + craftedItemResult } const handleUseItem = async (itemId: string) => { @@ -779,6 +785,9 @@ export function useGameEngine( // setMessage('Crafting...') // Loading state ok to keep specific or remove? Let's remove to avoid spam const response = await api.post('/api/game/craft_item', { item_id: itemId }) addLocationMessage(response.data.message || 'Item crafted!') + if (response.data.item) { + setCraftedItemResult(response.data.item) + } await refreshWorkbenchData() } catch (error: any) { addLocationMessage(error.response?.data?.detail || 'Failed to craft item') @@ -870,7 +879,8 @@ export function useGameEngine( in_combat: true, combat_over: false, player_won: false, - combat: response.data.combat + combat: response.data.combat, + player_effects: response.data.player_effects || [] }) setEnemyName(response.data.combat.npc_name) @@ -908,6 +918,10 @@ export function useGameEngine( response.data.quest_updates.forEach((q: any) => handleQuestUpdate(q)) } + // if (response.data.equipment) { + // setEquipment(response.data.equipment) + // } + return response.data } catch (error: any) { setMessage(error.response?.data?.detail || 'Combat action failed') @@ -941,7 +955,9 @@ export function useGameEngine( const handlePvPAction = async (action: string, _targetId: number) => { try { let payload: any = { action } - if (action.includes(':')) { + if (action.startsWith('skill:')) { + payload = { action: 'skill', skill_id: action.substring(6) } + } else if (action.includes(':')) { const [act, itemId] = action.split(':') payload = { action: act, item_id: itemId } } @@ -1081,7 +1097,8 @@ export function useGameEngine( setCombatState({ in_combat: true, combat_over: false, - combat: combatRes.data.combat + combat: combatRes.data.combat, + player_effects: combatRes.data.player_effects || [] }) // Update enemy name/image state @@ -1118,6 +1135,12 @@ export function useGameEngine( if (playerData.max_stamina !== undefined) { mappedData.max_stamina = playerData.max_stamina } + if (playerData.status_effects !== undefined) { + mappedData.status_effects = playerData.status_effects + } + if (playerData.equipment !== undefined) { + setEquipment(playerData.equipment) + } // Update playerState with mapped fields if (Object.keys(mappedData).length > 0) { @@ -1259,6 +1282,8 @@ export function useGameEngine( setUncraftFilter, setInventoryFilter, setInventoryCategoryFilter, + setCraftedItemResult, + updateEquipment: (data: any) => setEquipment(data), // WebSocket helper functions refreshLocation, refreshCombat, diff --git a/pwa/src/i18n/locales/en.json b/pwa/src/i18n/locales/en.json index abca36f..bd4e17d 100644 --- a/pwa/src/i18n/locales/en.json +++ b/pwa/src/i18n/locales/en.json @@ -257,7 +257,11 @@ "agi": "AGI", "end": "END", "hpMax": "HP max", - "stmMax": "Stm max" + "stmMax": "Stm max", + "interval_turn": "turn", + "intervals_turn": "turns", + "interval_minute": "minute", + "intervals_minute": "minutes" }, "combat": { "title": "Combat", @@ -285,6 +289,14 @@ "yourTurnTimer": "Your Turn ({{time}})", "enemyTurnTimer": "Enemy Turn", "waiting": "Waiting for opponent...", + "intents": { + "label": "Next move:", + "defend": "Defending", + "flee": "Fleeing", + "buff": "Buffing", + "attack": "Attacking", + "charging": "Charging Attack!" + }, "messages": { "combat_start": "Combat started with {{enemy}}!", "player_attack": "You attack for {{damage}} damage!", @@ -298,8 +310,11 @@ "defend": "Defend", "flee": "Flee", "supplies": "Supplies", - "useItem": "Use Item" + "useItem": "Use Item", + "abilities": "Abilities" }, + "stamina": "Stamina", + "cooldown_turns": "{{turns}} turn cooldown", "status": { "attacking": "Attacking...", "defending": "Bracing for impact...", @@ -332,15 +347,33 @@ "weapon_broke": "Your {{item_name}} broke!", "item_broken": "Your {{emoji}} {{item_name}} broke!", "combat_crit": "CRITICAL HIT!", - "combat_dodge": "You Dodged the attack!", - "combat_block": "You Blocked the attack!", + "combat_dodge": "You dodged the attack!", + "combat_block": "You blocked the attack!", "xp_gain": "Gained {{amount}} XP", "flee_success": "You managed to escape!", + "flee_fail": "Failed to escape!", "defend": "You brace for impact!", "item_used": "Used {{item}}", "effect_applied": "Applied {{effect}} to {{target}}", "item_damage": "{{item}} deals {{damage}} damage!", - "damage_reduced": "Damage reduced by {{reduction}}%" + "damage_reduced": "Damage reduced by {{reduction}}%", + "skill_attack": "{{skill_icon}} {{skill_name}} hits for {{damage}} damage{{hits_text}}", + "skill_heal": "{{skill_icon}} {{skill_name}} heals for {{heal}} HP", + "skill_buff": "{{skill_icon}} {{skill_name}} activated", + "skill_effect": "{{message}}", + "skill_analyze": "{{skill_icon}} Target analyzed!", + "enemy_enraged": "{{npc_name}} is enraged!", + "enemy_defend": "Enemy recovers {{heal}} HP", + "enemy_special": "Enemy uses a special attack for {{damage}} damage!", + "effect_bleeding": "Bleeding for {{damage}} damage", + "effect_heal": "Recovered {{heal}} HP", + "effect_damage": "Took {{damage}} damage from status effects", + "effect_damage_npc": "The enemy took {{damage}} damage from status effects", + "level_up": "Level up! You are now level {{new_level}}!", + "item_heal": "Healed for {{heal}} HP", + "item_restore": "Restored {{amount}} {{stat}}", + "died": "You have been defeated!", + "turns_remaining": "{{turns}} turns remaining" }, "modal": { "supplies_title": "Combat Supplies", diff --git a/pwa/src/i18n/locales/es.json b/pwa/src/i18n/locales/es.json index 39a1735..8468355 100644 --- a/pwa/src/i18n/locales/es.json +++ b/pwa/src/i18n/locales/es.json @@ -254,8 +254,12 @@ "str": "FUE", "agi": "AGI", "end": "RES", - "hpMax": "Vida máx", - "stmMax": "Agua. máx" + "hpMax": "PS máx", + "stmMax": "Ag máx", + "interval_turn": "turno", + "intervals_turn": "turnos", + "interval_minute": "minuto", + "intervals_minute": "minutos" }, "combat": { "title": "Combate", @@ -284,6 +288,14 @@ "yourTurnTimer": "Tu Turno ({{time}})", "enemyTurnTimer": "Turno del Enemigo", "waiting": "Esperando al oponente...", + "intents": { + "label": "Próximo movimiento:", + "defend": "Defendiendo", + "flee": "Huyendo", + "buff": "Potenciándose", + "attack": "Atacando", + "charging": "¡Ataque Cargado!" + }, "messages": { "combat_start": "¡Combate iniciado con {{enemy}}!", "player_attack": "¡Atacas por {{damage}} de daño!", @@ -296,8 +308,11 @@ "defend": "Defender", "flee": "Huir", "supplies": "Suministros", - "useItem": "Usar Objeto" + "useItem": "Usar Objeto", + "abilities": "Habilidades" }, + "stamina": "Aguante", + "cooldown_turns": "{{turns}} turnos de espera", "status": { "attacking": "Atacando...", "defending": "Preparándose...", @@ -339,7 +354,24 @@ "item_used": "Usaste {{item}}", "effect_applied": "Aplicado {{effect}} a {{target}}", "item_damage": "{{item}} inflige {{damage}} de daño!", - "damage_reduced": "Daño reducido en {{reduction}}%" + "damage_reduced": "Daño reducido en {{reduction}}%", + "skill_attack": "{{skill_icon}} {{skill_name}} golpea por {{damage}} de daño{{hits_text}}", + "skill_heal": "{{skill_icon}} {{skill_name}} cura {{heal}} PS", + "skill_buff": "{{skill_icon}} {{skill_name}} activado", + "skill_effect": "{{message}}", + "skill_analyze": "{{skill_icon}} ¡Objetivo analizado!", + "enemy_enraged": "¡{{npc_name}} está enfurecido!", + "enemy_defend": "El enemigo recupera {{heal}} PS", + "enemy_special": "¡El enemigo usa un ataque especial por {{damage}} de daño!", + "effect_bleeding": "Sangrado por {{damage}} de daño", + "effect_heal": "Recuperaste {{heal}} PS", + "effect_damage": "Recibiste {{damage}} de daño por efectos de estado", + "effect_damage_npc": "El enemigo recibió {{damage}} de daño por efectos de estado", + "level_up": "¡Subiste de nivel! ¡Ahora eres nivel {{new_level}}!", + "item_heal": "Curaste {{heal}} PS", + "item_restore": "Restauraste {{amount}} de {{stat}}", + "died": "¡Has sido derrotado!", + "turns_remaining": "{{turns}} turnos restantes" }, "modal": { "supplies_title": "Suministros de Combate", diff --git a/pwa/src/utils/i18nUtils.ts b/pwa/src/utils/i18nUtils.ts index 5c1f631..cc35f96 100644 --- a/pwa/src/utils/i18nUtils.ts +++ b/pwa/src/utils/i18nUtils.ts @@ -7,25 +7,37 @@ export type I18nString = string | { [key: string]: string } * @param value The value to translate (string or object with language keys) * @returns The translated string for the current language, or fallback to English/first available */ -export const getTranslatedText = (value: I18nString | undefined | null): string => { +export const getTranslatedText = (value: I18nString | undefined | null, vars?: Record): string => { if (!value) return '' - // If it's already a string, return it - if (typeof value === 'string') return value + let text = typeof value === 'string' ? value : ''; - // If it's an object, try to get the current language - const currentLang = i18n.language || 'en' + if (!text && typeof value === 'object') { + const objValue = value as Record; + const currentLang = i18n.language || 'en'; - // 1. Try current language - if (value[currentLang]) return value[currentLang] + // 1. Try current language + if (objValue[currentLang]) { + text = objValue[currentLang]; + } + // 2. Try English fallback + else if (objValue['en']) { + text = objValue['en']; + } + // 3. Return the first available key + else { + const firstKey = Object.keys(objValue)[0]; + if (firstKey) text = objValue[firstKey]; + } + } - // 2. Try English fallback - if (value['en']) return value['en'] + if (!text) return ''; - // 3. Return the first available key - const firstKey = Object.keys(value)[0] - if (firstKey) return value[firstKey] + if (vars) { + Object.entries(vars).forEach(([k, v]) => { + text = text.replace(new RegExp(`{{${k}}}`, 'g'), String(v)); + }); + } - // 4. Fallback empty - return '' + return text; }