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 = () => (
{getTranslatedText(skill.description)}
+{getTranslatedText(skill.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}
{getTranslatedText(perk.description)}
+{getTranslatedText(perk.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}
{t('crafting.confirmSalvage', { name: getTranslatedText(selectedItem.name) })} (x{salvageQuantity})
+