From 540df02ae7af13b39a9ce9e0cdb48ae434c32d64 Mon Sep 17 00:00:00 2001 From: Joan Date: Wed, 25 Feb 2026 12:00:06 +0100 Subject: [PATCH] Pre-combat-refactor: current state with PvP sync, boss setup scripts, combat fixes --- add_boss.py | 26 + api/routers/combat.py | 923 ++++++++++++++++++++- api/setup_test_env.py | 77 ++ gamedata/npcs.json | 25 + pwa/src/components/common/GameDropdown.tsx | 7 +- pwa/src/components/game/Combat.tsx | 4 + setup_boss.py | 60 ++ setup_boss.sql | 45 + setup_boss_host.py | 42 + setup_test_env.py | 100 +++ update_pvp.py | 521 ++++++++++++ 11 files changed, 1825 insertions(+), 5 deletions(-) create mode 100644 add_boss.py create mode 100644 api/setup_test_env.py create mode 100644 setup_boss.py create mode 100644 setup_boss.sql create mode 100644 setup_boss_host.py create mode 100644 setup_test_env.py create mode 100644 update_pvp.py diff --git a/add_boss.py b/add_boss.py new file mode 100644 index 0000000..d694d51 --- /dev/null +++ b/add_boss.py @@ -0,0 +1,26 @@ +import json + +with open('gamedata/npcs.json', 'r') as f: + data = json.load(f) + +if 'test_boss' not in data['npcs']: + data['npcs']['test_boss'] = { + 'name': {'en': 'Level 50 Test Boss', 'es': 'Jefe de Prueba Nivel 50'}, + 'description': {'en': 'A huge terrifying monster.', 'es': 'Un monstruo enorme y aterrador.'}, + 'emoji': '๐Ÿ‘น', + 'hp_min': 1000, + 'hp_max': 1500, + 'damage_min': 25, + 'damage_max': 45, + 'defense': 15, + 'xp_reward': 500, + 'loot_table': [], + 'flee_chance": 0.0, + 'status_inflict_chance': 0.5, + 'death_message': {'en': 'The boss is defeated.', 'es': 'El jefe ha sido derrotado.'} + } + with open('gamedata/npcs.json', 'w') as f: + json.dump(data, f, indent=2) + print("Boss added.") +else: + print("Boss exists.") diff --git a/api/routers/combat.py b/api/routers/combat.py index 2630431..b304345 100644 --- a/api/routers/combat.py +++ b/api/routers/combat.py @@ -572,6 +572,374 @@ async def combat_action( 'npc_hp': new_npc_hp }) + + elif req.action == 'skill': + if not req.item_id: + raise HTTPException(status_code=400, detail="skill_id (passed as item_id) required") + + from ..services.skills import skills_manager + skill_id = req.item_id + skill = skills_manager.get_skill(skill_id) + if not skill: + raise HTTPException(status_code=404, detail="Skill not found") + + # Check unlocked + stat_val = current_player.get(skill.stat_requirement, 0) + if stat_val < skill.stat_threshold or current_player['level'] < skill.level_requirement: + raise HTTPException(status_code=400, detail="Skill not unlocked") + + # Check cooldown + active_effects = await db.get_player_effects(current_player['id']) + cd_source = f"cd:{skill.id}" + for eff in active_effects: + if eff.get('source') == cd_source and eff.get('ticks_remaining', 0) > 0: + raise HTTPException(status_code=400, detail=f"Skill on cooldown ({eff['ticks_remaining']} turns)") + + # Check stamina + if current_player['stamina'] < skill.stamina_cost: + raise HTTPException(status_code=400, detail="Not enough stamina") + + # Deduct stamina + new_stamina = current_player['stamina'] - skill.stamina_cost + await db.update_player_stamina(current_player['id'], new_stamina) + current_player['stamina'] = new_stamina + + # Add cooldown effect + if skill.cooldown > 0: + await db.add_effect( + player_id=current_player['id'], + effect_name=f"{skill.id}_cooldown", + effect_icon="โณ", + effect_type="cooldown", + value=0, + ticks_remaining=skill.cooldown, + persist_after_combat=False, + source=cd_source + ) + + # Get weapon info + equipment = await db.get_all_equipment(current_player['id']) + weapon_damage = 0 + weapon_inv_id = None + inv_item = None + weapon_def = None + if equipment.get('weapon') and equipment['weapon']: + weapon_slot = equipment['weapon'] + inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id']) + if inv_item: + weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id']) + if weapon_def and weapon_def.stats: + weapon_damage = random.randint( + weapon_def.stats.get('damage_min', 0), + weapon_def.stats.get('damage_max', 0) + ) + weapon_inv_id = inv_item['id'] + + effects = skill.effects + new_opponent_hp = opponent['hp'] + damage_done = 0 + actual_damage = 0 + armor_absorbed = 0 + + # Damage skills + if 'damage_multiplier' in effects: + base_damage = 5 + strength_bonus = int(current_player['strength'] * 1.5) + level_bonus = current_player['level'] + variance = random.randint(-2, 2) + raw_damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) + + multiplier = effects['damage_multiplier'] + + if 'execute_threshold' in effects: + opponent_hp_pct = opponent['hp'] / opponent['max_hp'] if opponent['max_hp'] > 0 else 1 + if opponent_hp_pct <= effects['execute_threshold']: + multiplier = effects.get('execute_multiplier', multiplier) + + damage = max(1, int(raw_damage * multiplier)) + if effects.get('guaranteed_crit'): + damage = int(damage * 1.5) + + num_hits = effects.get('hits', 1) + + for hit in range(num_hits): + hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier)) + absorbed, broken_armor = await reduce_armor_durability(opponent['id'], hit_dmg) + armor_absorbed += absorbed + + for broken in broken_armor: + messages.append(create_combat_message( + "item_broken", + origin="enemy", + item_name=broken['name'], + emoji=broken['emoji'] + )) + last_action_text += f"\n๐Ÿ’” {opponent['name']}'s {broken['emoji']} {broken['name']} broke!" + + actual_hit = max(1, hit_dmg - absorbed) + damage_done += actual_hit + new_opponent_hp = max(0, new_opponent_hp - actual_hit) + + actual_damage = damage_done + + messages.append(create_combat_message( + "skill_attack", + origin="player", + damage=damage_done, + skill_name=skill.name, + skill_icon=skill.icon, + hits=num_hits + )) + last_action_text = f"{current_player['name']} used {skill.name} on {opponent['name']} for {damage_done} damage! (Armor absorbed {armor_absorbed})" + + # Lifesteal + if 'lifesteal' in effects: + heal_amount = int(damage_done * effects['lifesteal']) + new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount) + if new_hp > current_player['hp']: + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + messages.append(create_combat_message( + "skill_heal", origin="player", heal=heal_amount, skill_icon="๐Ÿฉธ" + )) + + # Poison DoT + if 'poison_damage' in effects: + await db.add_effect( + player_id=opponent['id'], + effect_name="Poison", + effect_icon="๐Ÿงช", + effect_type="damage", + damage_per_tick=effects['poison_damage'], + ticks_remaining=effects['poison_duration'], + persist_after_combat=True, + source=f"skill_poison:{skill.id}" + ) + messages.append(create_combat_message( + "skill_effect", origin="player", message=f"๐Ÿงช Poisoned! ({effects['poison_damage']} dmg/turn)" + )) + + # Stun chance + if 'stun_chance' in effects and random.random() < effects['stun_chance']: + # Stun in PvP can be modeled as taking away a turn + await db.add_effect( + player_id=opponent['id'], + effect_name="Stunned", + effect_icon="๐Ÿ’ซ", + effect_type="debuff", + ticks_remaining=1, + persist_after_combat=False, + source="skill_stun" + ) + messages.append(create_combat_message( + "skill_effect", origin="player", message="๐Ÿ’ซ Stunned! (Currently skip effect)" + )) + + # Weapon durability + if weapon_inv_id and inv_item and inv_item.get('unique_item_id'): + new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1) + if new_durability is None: + messages.append(create_combat_message("weapon_broke", origin="player", item_name=weapon_def.name if weapon_def else "weapon")) + await db.unequip_item(current_player['id'], 'weapon') + + # Heal skills + if 'heal_percent' in effects: + heal_amount = int(current_player['max_hp'] * effects['heal_percent']) + new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount) + actual_heal = new_hp - current_player['hp'] + if actual_heal > 0: + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + messages.append(create_combat_message( + "skill_heal", origin="player", heal=actual_heal, skill_icon=skill.icon + )) + last_action_text = f"{current_player['name']} used {skill.name} and healed for {actual_heal} HP!" + + # Fortify + if 'armor_boost' in effects: + await db.add_effect( + player_id=current_player['id'], + effect_name="Fortify", + effect_icon="๐Ÿ›ก๏ธ", + effect_type="buff", + value=effects['armor_boost'], + ticks_remaining=effects['duration'], + persist_after_combat=False, + source=f"skill_fortify:{skill.id}" + ) + messages.append(create_combat_message( + "skill_effect", origin="player", message=f"๐Ÿ›ก๏ธ Fortified! (+{effects['armor_boost']} Armor)" + )) + last_action_text = f"{current_player['name']} used {skill.name} and bolstered their defenses!" + + # Process opponent HP if damage done + if damage_done > 0: + await db.update_player(opponent['id'], hp=new_opponent_hp) + if new_opponent_hp <= 0: + last_action_text += f"\n๐Ÿ† {current_player['name']} has defeated {opponent['name']}!" + messages.append(create_combat_message("victory", origin="neutral", npc_name=opponent['name'])) + combat_over = True + winner_id = current_player['id'] + + await db.update_player(opponent['id'], hp=0, is_dead=True) + + # Create corpse + import json + import time as time_module + inventory = await db.get_inventory(opponent['id']) + inventory_items = [] + for inv_item in inventory: + item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) + inventory_items.append({ + 'item_id': inv_item['item_id'], + 'name': item_def.name if item_def else inv_item['item_id'], + 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '๐Ÿ“ฆ', + 'quantity': inv_item['quantity'], + 'durability': inv_item.get('durability'), + 'max_durability': inv_item.get('max_durability'), + 'tier': inv_item.get('tier') + }) + + corpse_data = None + if inventory_items: + corpse_id = await db.create_player_corpse( + player_name=opponent['name'], + location_id=opponent['location_id'], + items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) + ) + await db.clear_inventory(opponent['id']) + corpse_data = { + "id": f"player_{corpse_id}", + "type": "player", + "name": f"{opponent['name']}'s Corpse", + "emoji": "โšฐ๏ธ", + "player_name": opponent['name'], + "loot_count": len(inventory_items), + "items": inventory_items, + "timestamp": time_module.time() + } + + # Update statistics + await db.update_player_statistics(opponent['id'], pvp_deaths=1, pvp_combats_lost=1, pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True) + await db.update_player_statistics(current_player['id'], players_killed=1, pvp_combats_won=1, pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True) + + # Broadcast corpse + broadcast_data = { + "message": get_game_message('pvp_defeat_broadcast', locale, opponent=opponent['name'], winner=current_player['name']), + "action": "player_died", + "player_id": opponent['id'] + } + if corpse_data: + broadcast_data["corpse"] = corpse_data + + await manager.send_to_location( + location_id=opponent['location_id'], + message={ + "type": "location_update", + "data": broadcast_data, + "timestamp": datetime.utcnow().isoformat() + } + ) + + await db.end_pvp_combat(pvp_combat['id']) + else: + await db.update_player_statistics(current_player['id'], pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True) + await db.update_player_statistics(opponent['id'], pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True) + + # End of turn swap + updates = { + 'turn': 'defender' if is_attacker else 'attacker', + 'turn_started_at': time.time(), + 'last_action': f"{last_action_text}|{time.time()}" + } + await db.update_pvp_combat(pvp_combat['id'], updates) + + else: + # Skill didn't do damage, but turn still ends + updates = { + 'turn': 'defender' if is_attacker else 'attacker', + 'turn_started_at': time.time(), + 'last_action': f"{last_action_text}|{time.time()}" + } + await db.update_pvp_combat(pvp_combat['id'], updates) + + elif req.action == 'use_item': + if not req.item_id: + raise HTTPException(status_code=400, detail="item_id required for use_item action") + + player_inventory = await db.get_inventory(current_player['id']) + inv_item = next((item for item in player_inventory if item['item_id'] == req.item_id), None) + + if not inv_item: + raise HTTPException(status_code=400, detail="Item not found in inventory") + + item_def = ITEMS_MANAGER.get_item(req.item_id) + if not item_def or not item_def.combat_usable: + raise HTTPException(status_code=400, detail="This item cannot be used in combat") + + item_name = get_locale_string(item_def.name, locale) + effects_applied = [] + + if item_def.effects.get('status_effect'): + status_data = item_def.effects['status_effect'] + await db.add_effect( + player_id=current_player['id'], + effect_name=status_data['name'], + effect_icon=status_data.get('icon', 'โœจ'), + effect_type=status_data.get('type', 'buff'), + damage_per_tick=status_data.get('damage_per_tick', 0), + value=status_data.get('value', 0), + ticks_remaining=status_data.get('ticks', 3), + persist_after_combat=True, + source=f"item:{item_def.id}" + ) + effects_applied.append(f"Applied {status_data['name']}") + + if item_def.effects.get('cures'): + for cure_effect in item_def.effects['cures']: + if await db.remove_effect(current_player['id'], cure_effect): + effects_applied.append(f"Cured {cure_effect}") + + if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0: + item_effectiveness = current_player_stats.get('item_effectiveness', 1.0) + restore_amount = int(item_def.effects['hp_restore'] * item_effectiveness) + new_hp = min(current_player['max_hp'], current_player['hp'] + restore_amount) + actual_heal = new_hp - current_player['hp'] + if actual_heal > 0: + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + effects_applied.append(get_game_message('healed_amount_text', locale, amount=actual_heal)) + messages.append(create_combat_message("item_heal", origin="player", heal=actual_heal)) + + if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0: + item_effectiveness = current_player_stats.get('item_effectiveness', 1.0) + restore_amount = int(item_def.effects['stamina_restore'] * item_effectiveness) + new_stamina = min(current_player['max_stamina'], current_player['stamina'] + restore_amount) + actual_restore = new_stamina - current_player['stamina'] + if actual_restore > 0: + await db.update_player_stamina(current_player['id'], new_stamina) + effects_applied.append(f"Restored {actual_restore} stamina") + messages.append(create_combat_message("item_restore", origin="player", stat="stamina", amount=actual_restore)) + + if inv_item['quantity'] > 1: + await db.update_inventory_quantity(inv_item['id'], inv_item['quantity'] - 1) + else: + await db.remove_from_inventory(inv_item['id']) + + messages.append(create_combat_message( + "use_item", origin="player", item_name=item_name, + message=get_game_message('used_item_text', locale, name=item_name) + " (" + ", ".join(effects_applied) + ")" + )) + last_action_text = f"{current_player['name']} used {item_name}!" + + # End of turn swap + updates = { + 'turn': 'defender' if is_attacker else 'attacker', + 'turn_started_at': time.time(), + 'last_action': f"{last_action_text}|{time.time()}" + } + await db.update_pvp_combat(pvp_combat['id'], updates) + elif req.action == 'flee': # 50% chance to flee if random.random() < 0.5: @@ -993,18 +1361,92 @@ async def combat_action( # Create corpse import json + # Convert CorpseLoot objects to dicts + corpse_loot = npc_def.corpse_loot if hasattr(npc_def, 'corpse_loot') else [] + corpse_loot_dicts = [] + for loot in corpse_loot: + if hasattr(loot, '__dict__'): + corpse_loot_dicts.append({ + 'item_id': loot.item_id, + 'quantity_min': loot.quantity_min, + 'quantity_max': loot.quantity_max, + 'required_tool': loot.required_tool + }) + else: + corpse_loot_dicts.append(loot) + await db.create_npc_corpse( combat['npc_id'], combat.get('location_id', player.get('location_id', '')), - json.dumps(generated_loot) + json.dumps(corpse_loot_dicts) ) + # Update quests + try: + if QUESTS_DATA: + active_quests = await db.get_character_quests(player['id']) + for q_record in active_quests: + if q_record['status'] != 'active': + continue + + q_def = QUESTS_DATA.get(q_record['quest_id']) + if not q_def: continue + + objectives = q_def.get('objectives', []) + current_progress = q_record.get('progress') or {} + new_progress = current_progress.copy() + progres_changed = False + + for obj in objectives: + if obj['type'] == 'kill_count' and obj['target'] == combat['npc_id']: + current_count = current_progress.get(obj['target'], 0) + if current_count < obj['count']: + new_progress[obj['target']] = current_count + 1 + progres_changed = True + + if progres_changed: + progress_str = "" + for obj in objectives: + target = obj['target'] + req_count = obj['count'] + curr = new_progress.get(target, 0) + if obj['type'] == 'kill_count': + if target == combat['npc_id']: + progress_str = f" ({curr}/{req_count})" + + await db.update_quest_progress(player['id'], q_record['quest_id'], new_progress, 'active') + messages.append(create_combat_message( + "quest_update", + origin="system", + message=f"{get_locale_string(q_def['title'], locale)}{progress_str}" + )) + except Exception as e: + logger.error(f"Failed to update quest progress in skill execution: {e}") + await db.remove_non_persistent_effects(player['id']) + await db.end_combat(player['id']) + + # Update Redis cache + if redis_manager: + await redis_manager.delete_combat_state(player['id']) + await redis_manager.update_player_session_field(player['id'], 'xp', current_xp) + else: + # NPC turn for skill usage + from ..services.stats import calculate_derived_stats + stats = await calculate_derived_stats(player['id'], redis_manager) + npc_attack_messages, player_defeated = await game_logic.npc_attack( + 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_def, + reduce_armor_durability, + player_stats=stats + ) messages.extend(npc_attack_messages) if player_defeated: await db.remove_non_persistent_effects(player['id']) combat_over = True - elif req.action == 'use_item': combat_over = False # Validate item_id provided @@ -1535,10 +1977,117 @@ async def pvp_combat_action( combat_over = False winner_id = None + # Track the last action string for DB history last_action_text = "" - if req.action == 'attack': + # Process status effects (bleeding, poison, etc.) before action + active_effects = await db.tick_player_effects(current_player['id']) + + if active_effects: + from ..game_logic import calculate_status_impact + total_impact = calculate_status_impact(active_effects) + + if total_impact > 0: + damage = total_impact + new_hp = max(0, current_player['hp'] - damage) + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + + messages.append(create_combat_message( + "effect_damage", + origin="player", + damage=damage, + effect_name="status effects" + )) + + if new_hp <= 0: + messages.append(create_combat_message("died", origin="player", message="You died from status effects!")) + combat_over = True + winner_id = opponent['id'] + + # Update current player to dead state + await db.update_player(current_player['id'], hp=0, is_dead=True) + + # Create corpse + import json + import time as time_module + inventory = await db.get_inventory(current_player['id']) + inventory_items = [] + for inv_item in inventory: + item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) + inventory_items.append({ + 'item_id': inv_item['item_id'], + 'name': item_def.name if item_def else inv_item['item_id'], + 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '๐Ÿ“ฆ', + 'quantity': inv_item['quantity'], + 'durability': inv_item.get('durability'), + 'max_durability': inv_item.get('max_durability'), + 'tier': inv_item.get('tier') + }) + + corpse_data = None + if inventory_items: + corpse_id = await db.create_player_corpse( + player_name=current_player['name'], + location_id=current_player['location_id'], + items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) + ) + await db.clear_inventory(current_player['id']) + corpse_data = { + "id": f"player_{corpse_id}", + "type": "player", + "name": f"{current_player['name']}'s Corpse", + "emoji": "โšฐ๏ธ", + "player_name": current_player['name'], + "loot_count": len(inventory_items), + "items": inventory_items, + "timestamp": time_module.time() + } + + # Update PvP statistics for both players + await db.update_player_statistics(current_player['id'], pvp_deaths=1, pvp_combats_lost=1, increment=True) + await db.update_player_statistics(opponent['id'], players_killed=1, pvp_combats_won=1, increment=True) + + # Broadcast corpse + broadcast_data = { + "message": get_game_message('pvp_defeat_broadcast', locale, opponent=current_player['name'], winner=opponent['name']), + "action": "player_died", + "player_id": current_player['id'] + } + if corpse_data: + broadcast_data["corpse"] = corpse_data + + await manager.send_to_location( + location_id=current_player['location_id'], + message={ + "type": "location_update", + "data": broadcast_data, + "timestamp": datetime.utcnow().isoformat() + } + ) + + await db.end_pvp_combat(pvp_combat['id']) + + elif total_impact < 0: + heal = abs(total_impact) + new_hp = min(current_player_stats.get('max_hp', current_player['max_hp']), current_player['hp'] + heal) + actual_heal = new_hp - current_player['hp'] + + if actual_heal > 0: + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + messages.append(create_combat_message( + "effect_heal", + origin="player", + heal=actual_heal, + effect_name="status effects" + )) + + # Stop processing action if player died from status effects + if combat_over: + pass + elif req.action == 'attack': # Calculate damage (unified formula with derived stats) base_damage = current_player_stats.get('attack_power', 5) @@ -1736,6 +2285,374 @@ async def pvp_combat_action( await db.update_pvp_combat(pvp_combat['id'], updates) await db.update_player_statistics(current_player['id'], damage_dealt=damage, increment=True) + + elif req.action == 'skill': + if not req.item_id: + raise HTTPException(status_code=400, detail="skill_id (passed as item_id) required") + + from ..services.skills import skills_manager + skill_id = req.item_id + skill = skills_manager.get_skill(skill_id) + if not skill: + raise HTTPException(status_code=404, detail="Skill not found") + + # Check unlocked + stat_val = current_player.get(skill.stat_requirement, 0) + if stat_val < skill.stat_threshold or current_player['level'] < skill.level_requirement: + raise HTTPException(status_code=400, detail="Skill not unlocked") + + # Check cooldown + active_effects = await db.get_player_effects(current_player['id']) + cd_source = f"cd:{skill.id}" + for eff in active_effects: + if eff.get('source') == cd_source and eff.get('ticks_remaining', 0) > 0: + raise HTTPException(status_code=400, detail=f"Skill on cooldown ({eff['ticks_remaining']} turns)") + + # Check stamina + if current_player['stamina'] < skill.stamina_cost: + raise HTTPException(status_code=400, detail="Not enough stamina") + + # Deduct stamina + new_stamina = current_player['stamina'] - skill.stamina_cost + await db.update_player_stamina(current_player['id'], new_stamina) + current_player['stamina'] = new_stamina + + # Add cooldown effect + if skill.cooldown > 0: + await db.add_effect( + player_id=current_player['id'], + effect_name=f"{skill.id}_cooldown", + effect_icon="โณ", + effect_type="cooldown", + value=0, + ticks_remaining=skill.cooldown, + persist_after_combat=False, + source=cd_source + ) + + # Get weapon info + equipment = await db.get_all_equipment(current_player['id']) + weapon_damage = 0 + weapon_inv_id = None + inv_item = None + weapon_def = None + if equipment.get('weapon') and equipment['weapon']: + weapon_slot = equipment['weapon'] + inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id']) + if inv_item: + weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id']) + if weapon_def and weapon_def.stats: + weapon_damage = random.randint( + weapon_def.stats.get('damage_min', 0), + weapon_def.stats.get('damage_max', 0) + ) + weapon_inv_id = inv_item['id'] + + effects = skill.effects + new_opponent_hp = opponent['hp'] + damage_done = 0 + actual_damage = 0 + armor_absorbed = 0 + + # Damage skills + if 'damage_multiplier' in effects: + base_damage = 5 + strength_bonus = int(current_player['strength'] * 1.5) + level_bonus = current_player['level'] + variance = random.randint(-2, 2) + raw_damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) + + multiplier = effects['damage_multiplier'] + + if 'execute_threshold' in effects: + opponent_hp_pct = opponent['hp'] / opponent['max_hp'] if opponent['max_hp'] > 0 else 1 + if opponent_hp_pct <= effects['execute_threshold']: + multiplier = effects.get('execute_multiplier', multiplier) + + damage = max(1, int(raw_damage * multiplier)) + if effects.get('guaranteed_crit'): + damage = int(damage * 1.5) + + num_hits = effects.get('hits', 1) + + for hit in range(num_hits): + hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier)) + absorbed, broken_armor = await reduce_armor_durability(opponent['id'], hit_dmg) + armor_absorbed += absorbed + + for broken in broken_armor: + messages.append(create_combat_message( + "item_broken", + origin="enemy", + item_name=broken['name'], + emoji=broken['emoji'] + )) + last_action_text += f"\n๐Ÿ’” {opponent['name']}'s {broken['emoji']} {broken['name']} broke!" + + actual_hit = max(1, hit_dmg - absorbed) + damage_done += actual_hit + new_opponent_hp = max(0, new_opponent_hp - actual_hit) + + actual_damage = damage_done + + messages.append(create_combat_message( + "skill_attack", + origin="player", + damage=damage_done, + skill_name=skill.name, + skill_icon=skill.icon, + hits=num_hits + )) + last_action_text = f"{current_player['name']} used {skill.name} on {opponent['name']} for {damage_done} damage! (Armor absorbed {armor_absorbed})" + + # Lifesteal + if 'lifesteal' in effects: + heal_amount = int(damage_done * effects['lifesteal']) + new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount) + if new_hp > current_player['hp']: + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + messages.append(create_combat_message( + "skill_heal", origin="player", heal=heal_amount, skill_icon="๐Ÿฉธ" + )) + + # Poison DoT + if 'poison_damage' in effects: + await db.add_effect( + player_id=opponent['id'], + effect_name="Poison", + effect_icon="๐Ÿงช", + effect_type="damage", + damage_per_tick=effects['poison_damage'], + ticks_remaining=effects['poison_duration'], + persist_after_combat=True, + source=f"skill_poison:{skill.id}" + ) + messages.append(create_combat_message( + "skill_effect", origin="player", message=f"๐Ÿงช Poisoned! ({effects['poison_damage']} dmg/turn)" + )) + + # Stun chance + if 'stun_chance' in effects and random.random() < effects['stun_chance']: + # Stun in PvP can be modeled as taking away a turn + await db.add_effect( + player_id=opponent['id'], + effect_name="Stunned", + effect_icon="๐Ÿ’ซ", + effect_type="debuff", + ticks_remaining=1, + persist_after_combat=False, + source="skill_stun" + ) + messages.append(create_combat_message( + "skill_effect", origin="player", message="๐Ÿ’ซ Stunned! (Currently skip effect)" + )) + + # Weapon durability + if weapon_inv_id and inv_item and inv_item.get('unique_item_id'): + new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1) + if new_durability is None: + messages.append(create_combat_message("weapon_broke", origin="player", item_name=weapon_def.name if weapon_def else "weapon")) + await db.unequip_item(current_player['id'], 'weapon') + + # Heal skills + if 'heal_percent' in effects: + heal_amount = int(current_player['max_hp'] * effects['heal_percent']) + new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount) + actual_heal = new_hp - current_player['hp'] + if actual_heal > 0: + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + messages.append(create_combat_message( + "skill_heal", origin="player", heal=actual_heal, skill_icon=skill.icon + )) + last_action_text = f"{current_player['name']} used {skill.name} and healed for {actual_heal} HP!" + + # Fortify + if 'armor_boost' in effects: + await db.add_effect( + player_id=current_player['id'], + effect_name="Fortify", + effect_icon="๐Ÿ›ก๏ธ", + effect_type="buff", + value=effects['armor_boost'], + ticks_remaining=effects['duration'], + persist_after_combat=False, + source=f"skill_fortify:{skill.id}" + ) + messages.append(create_combat_message( + "skill_effect", origin="player", message=f"๐Ÿ›ก๏ธ Fortified! (+{effects['armor_boost']} Armor)" + )) + last_action_text = f"{current_player['name']} used {skill.name} and bolstered their defenses!" + + # Process opponent HP if damage done + if damage_done > 0: + await db.update_player(opponent['id'], hp=new_opponent_hp) + if new_opponent_hp <= 0: + last_action_text += f"\n๐Ÿ† {current_player['name']} has defeated {opponent['name']}!" + messages.append(create_combat_message("victory", origin="neutral", npc_name=opponent['name'])) + combat_over = True + winner_id = current_player['id'] + + await db.update_player(opponent['id'], hp=0, is_dead=True) + + # Create corpse + import json + import time as time_module + inventory = await db.get_inventory(opponent['id']) + inventory_items = [] + for inv_item in inventory: + item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) + inventory_items.append({ + 'item_id': inv_item['item_id'], + 'name': item_def.name if item_def else inv_item['item_id'], + 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '๐Ÿ“ฆ', + 'quantity': inv_item['quantity'], + 'durability': inv_item.get('durability'), + 'max_durability': inv_item.get('max_durability'), + 'tier': inv_item.get('tier') + }) + + corpse_data = None + if inventory_items: + corpse_id = await db.create_player_corpse( + player_name=opponent['name'], + location_id=opponent['location_id'], + items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) + ) + await db.clear_inventory(opponent['id']) + corpse_data = { + "id": f"player_{corpse_id}", + "type": "player", + "name": f"{opponent['name']}'s Corpse", + "emoji": "โšฐ๏ธ", + "player_name": opponent['name'], + "loot_count": len(inventory_items), + "items": inventory_items, + "timestamp": time_module.time() + } + + # Update statistics + await db.update_player_statistics(opponent['id'], pvp_deaths=1, pvp_combats_lost=1, pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True) + await db.update_player_statistics(current_player['id'], players_killed=1, pvp_combats_won=1, pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True) + + # Broadcast corpse + broadcast_data = { + "message": get_game_message('pvp_defeat_broadcast', locale, opponent=opponent['name'], winner=current_player['name']), + "action": "player_died", + "player_id": opponent['id'] + } + if corpse_data: + broadcast_data["corpse"] = corpse_data + + await manager.send_to_location( + location_id=opponent['location_id'], + message={ + "type": "location_update", + "data": broadcast_data, + "timestamp": datetime.utcnow().isoformat() + } + ) + + await db.end_pvp_combat(pvp_combat['id']) + else: + await db.update_player_statistics(current_player['id'], pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True) + await db.update_player_statistics(opponent['id'], pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True) + + # End of turn swap + updates = { + 'turn': 'defender' if is_attacker else 'attacker', + 'turn_started_at': time.time(), + 'last_action': f"{last_action_text}|{time.time()}" + } + await db.update_pvp_combat(pvp_combat['id'], updates) + + else: + # Skill didn't do damage, but turn still ends + updates = { + 'turn': 'defender' if is_attacker else 'attacker', + 'turn_started_at': time.time(), + 'last_action': f"{last_action_text}|{time.time()}" + } + await db.update_pvp_combat(pvp_combat['id'], updates) + + elif req.action == 'use_item': + if not req.item_id: + raise HTTPException(status_code=400, detail="item_id required for use_item action") + + player_inventory = await db.get_inventory(current_player['id']) + inv_item = next((item for item in player_inventory if item['item_id'] == req.item_id), None) + + if not inv_item: + raise HTTPException(status_code=400, detail="Item not found in inventory") + + item_def = ITEMS_MANAGER.get_item(req.item_id) + if not item_def or not item_def.combat_usable: + raise HTTPException(status_code=400, detail="This item cannot be used in combat") + + item_name = get_locale_string(item_def.name, locale) + effects_applied = [] + + if item_def.effects.get('status_effect'): + status_data = item_def.effects['status_effect'] + await db.add_effect( + player_id=current_player['id'], + effect_name=status_data['name'], + effect_icon=status_data.get('icon', 'โœจ'), + effect_type=status_data.get('type', 'buff'), + damage_per_tick=status_data.get('damage_per_tick', 0), + value=status_data.get('value', 0), + ticks_remaining=status_data.get('ticks', 3), + persist_after_combat=True, + source=f"item:{item_def.id}" + ) + effects_applied.append(f"Applied {status_data['name']}") + + if item_def.effects.get('cures'): + for cure_effect in item_def.effects['cures']: + if await db.remove_effect(current_player['id'], cure_effect): + effects_applied.append(f"Cured {cure_effect}") + + if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0: + item_effectiveness = current_player_stats.get('item_effectiveness', 1.0) + restore_amount = int(item_def.effects['hp_restore'] * item_effectiveness) + new_hp = min(current_player['max_hp'], current_player['hp'] + restore_amount) + actual_heal = new_hp - current_player['hp'] + if actual_heal > 0: + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + effects_applied.append(get_game_message('healed_amount_text', locale, amount=actual_heal)) + messages.append(create_combat_message("item_heal", origin="player", heal=actual_heal)) + + if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0: + item_effectiveness = current_player_stats.get('item_effectiveness', 1.0) + restore_amount = int(item_def.effects['stamina_restore'] * item_effectiveness) + new_stamina = min(current_player['max_stamina'], current_player['stamina'] + restore_amount) + actual_restore = new_stamina - current_player['stamina'] + if actual_restore > 0: + await db.update_player_stamina(current_player['id'], new_stamina) + effects_applied.append(f"Restored {actual_restore} stamina") + messages.append(create_combat_message("item_restore", origin="player", stat="stamina", amount=actual_restore)) + + if inv_item['quantity'] > 1: + await db.update_inventory_quantity(inv_item['id'], inv_item['quantity'] - 1) + else: + await db.remove_from_inventory(inv_item['id']) + + messages.append(create_combat_message( + "use_item", origin="player", item_name=item_name, + message=get_game_message('used_item_text', locale, name=item_name) + " (" + ", ".join(effects_applied) + ")" + )) + last_action_text = f"{current_player['name']} used {item_name}!" + + # End of turn swap + updates = { + 'turn': 'defender' if is_attacker else 'attacker', + 'turn_started_at': time.time(), + 'last_action': f"{last_action_text}|{time.time()}" + } + await db.update_pvp_combat(pvp_combat['id'], updates) + elif req.action == 'flee': # 50% chance to flee from PvP if random.random() < 0.5: diff --git a/api/setup_test_env.py b/api/setup_test_env.py new file mode 100644 index 0000000..cfe75cc --- /dev/null +++ b/api/setup_test_env.py @@ -0,0 +1,77 @@ +import asyncio +import json +import time +import os +from database import Database + +async def main(): + # 1. Update npcs.json to add a test boss + with open('../gamedata/npcs.json', 'r') as f: + data = json.load(f) + if 'test_boss' not in data['npcs']: + data['npcs']['test_boss'] = { + "name": {"en": "Level 50 Test Boss", "es": "Jefe de Prueba Nivel 50"}, + "description": {"en": "A huge terrifying monster.", "es": "Un monstruo enorme y aterrador."}, + "emoji": "๐Ÿ‘น", + "hp_min": 1000, + "hp_max": 1500, + "damage_min": 25, + "damage_max": 45, + "defense": 15, + "xp_reward": 500, + "loot_table": [], + "flee_chance": 0.0, + "status_inflict_chance": 0.5, + "death_message": {"en": "The boss is defeated.", "es": "El jefe ha sido derrotado."} + } + with open('../gamedata/npcs.json', 'w') as f: + json.dump(data, f, indent=2) + print("Added 'test_boss' to npcs.json") + + db = Database() + await db.connect() + + # 2. Get Jocaru + player = await db.fetch_one("SELECT * FROM characters WHERE name ILIKE 'Jocaru'") + if not player: + print("Player Jocaru not found!") + return + + pid = player['id'] + ploc = player['location_id'] + + # 3. Give items + items_to_give = [ + ('reinforced_pack', 1), + ('reinforced_bat', 1), + ('knife', 1), + ('first_aid_kit', 10), + ('mystery_pills', 5), + ('energy_bar', 10), + ('molotov', 5) + ] + for item_id, qty in items_to_give: + await db.add_item_to_inventory(pid, item_id, qty) + print("Granted test items and backpack.") + + # 4. Give XP to reach lvl 50 if needed + # Level 50 is base + (50 * multiplier) ... the logic is in check_and_apply_level_up + await db.execute("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid", {"pid": pid}) + print("Buffed Jocaru to lvl 50 manually.") + + # 5. Spawn enemies at player's location + now = time.time() + despawn = now + 86400 # 1 day + + enemies_to_spawn = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2 + for eid in enemies_to_spawn: + await db.execute( + "INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)", + {"nid": eid, "loc": ploc, "start": now, "end": despawn} + ) + print(f"Spawned {len(enemies_to_spawn)} enemies at {ploc}") + + await db.disconnect() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/gamedata/npcs.json b/gamedata/npcs.json index d23e68e..706b3e3 100644 --- a/gamedata/npcs.json +++ b/gamedata/npcs.json @@ -294,6 +294,31 @@ "en": "The scavenger's struggle ends. Survival has no mercy.", "es": "El deseo de supervivencia del escavador se agota. La supervivencia no tiene misericordia." } + }, + "test_boss": { + "npc_id": "test_boss", + "name": { + "en": "Level 50 Test Boss", + "es": "Jefe de Prueba Nivel 50" + }, + "description": { + "en": "A huge terrifying monster.", + "es": "Un monstruo enorme y aterrador." + }, + "emoji": "๐Ÿ‘น", + "hp_min": 1000, + "hp_max": 2000, + "damage_min": 25, + "damage_max": 65, + "defense": 15, + "xp_reward": 500, + "loot_table": [], + "flee_chance": 0.0, + "status_inflict_chance": 0.5, + "death_message": { + "en": "The boss is defeated.", + "es": "El jefe ha sido derrotado." + } } }, "danger_levels": { diff --git a/pwa/src/components/common/GameDropdown.tsx b/pwa/src/components/common/GameDropdown.tsx index f5e1337..1ee4c63 100644 --- a/pwa/src/components/common/GameDropdown.tsx +++ b/pwa/src/components/common/GameDropdown.tsx @@ -56,8 +56,11 @@ export const GameDropdown: React.FC = ({ document.addEventListener('mousedown', handleClickOutside); // Handle scroll to close the dropdown (prevents detached menu and layout shifts) - const handleScroll = () => { - onClose(); + const handleScroll = (event: Event) => { + // Only close if scrolling the main document/window, not a sub-container like combat log + if (event.target === document || event.target === window || event.target === document.documentElement || event.target === document.body) { + onClose(); + } }; window.addEventListener('scroll', handleScroll, true); diff --git a/pwa/src/components/game/Combat.tsx b/pwa/src/components/game/Combat.tsx index 4931ef0..237b8dd 100644 --- a/pwa/src/components/game/Combat.tsx +++ b/pwa/src/components/game/Combat.tsx @@ -439,6 +439,10 @@ export const Combat: React.FC = ({ } break; + case 'effect_damage': + addFloatingText(`-${data.damage}`, 'damage', origin === 'enemy' ? 'enemy' : 'player'); + break; + case 'effect_bleeding': addFloatingText(`-${data.damage}`, 'damage', origin === 'player' ? 'enemy' : 'player'); break; diff --git a/setup_boss.py b/setup_boss.py new file mode 100644 index 0000000..c15bcb8 --- /dev/null +++ b/setup_boss.py @@ -0,0 +1,60 @@ +import json +import asyncio +import os +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text +import time + +async def main(): + # Connect to DB using sqlalchemy + url = "postgresql+asyncpg://admin:password@echoes_of_the_ashes_db:5432/echoesoftheashes" + engine = create_async_engine(url) + + try: + async with engine.begin() as conn: + # Get Jocaru ID + res = await conn.execute(text("SELECT id, location_id FROM characters WHERE name ILIKE 'Jocaru'")) + row = res.first() + if not row: + print("Jocaru not found.") + return + + pid, loc = row[0], row[1] + print(f"Player Jocaru found (ID {pid}) at {loc}") + + # Buff to level 50 + await conn.execute(text("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid"), {"pid": pid}) + print("Set Jocaru to level 50 metrics.") + + # Give items directly via SQL + items = [ + ('reinforced_pack', 1), + ('reinforced_bat', 1), + ('combat_knife', 1), + ('first_aid_kit', 10), + ('mystery_pills', 5), + ('energy_bar', 10) + ] + for iid, qty in items: + await conn.execute( + text("INSERT INTO inventory (character_id, item_id, quantity) VALUES (:pid, :iid, :qty)"), + {"pid": pid, "iid": iid, "qty": qty} + ) + print("Gave items to Jocaru.") + + # Spawn enemies + now = time.time() + despawn = now + 86400 + + enemies = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2 + for eid in enemies: + await conn.execute( + text("INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)"), + {"nid": eid, "loc": loc, "start": now, "end": despawn} + ) + print(f"Spawned {len(enemies)} enemies at {loc}.") + + except Exception as e: + print(f"Error accessing DB natively: {e}") + +asyncio.run(main()) diff --git a/setup_boss.sql b/setup_boss.sql new file mode 100644 index 0000000..def23de --- /dev/null +++ b/setup_boss.sql @@ -0,0 +1,45 @@ +-- Buff player and get location into temporary variable +DO $$ +DECLARE + player_id INT; + loc_id VARCHAR; + start_ts FLOAT; + end_ts FLOAT; +BEGIN + SELECT id, location_id INTO player_id, loc_id FROM characters WHERE name ILIKE 'Jocaru'; + IF NOT FOUND THEN + RAISE NOTICE 'Player Jocaru not found'; + RETURN; + END IF; + + UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = player_id; + + -- Give items + INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'reinforced_pack', 1); + INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'reinforced_bat', 1); + INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'combat_knife', 1); + INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'first_aid_kit', 10); + INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'mystery_pills', 5); + INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'energy_bar', 10); + + -- Spawn enemies + start_ts := extract(epoch from now()); + end_ts := start_ts + 86400; + + -- 5 Raider Scouts + FOR i IN 1..5 LOOP + INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('raider_scout', loc_id, start_ts, end_ts); + END LOOP; + -- 5 Feral Dogs + FOR i IN 1..5 LOOP + INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('feral_dog', loc_id, start_ts, end_ts); + END LOOP; + -- 5 Mutant Rats + FOR i IN 1..5 LOOP + INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('mutant_rat', loc_id, start_ts, end_ts); + END LOOP; + -- 2 Test Bosses + FOR i IN 1..2 LOOP + INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('test_boss', loc_id, start_ts, end_ts); + END LOOP; +END $$; diff --git a/setup_boss_host.py b/setup_boss_host.py new file mode 100644 index 0000000..9180405 --- /dev/null +++ b/setup_boss_host.py @@ -0,0 +1,42 @@ +import asyncio +import time +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text + +async def main(): + url = "postgresql+asyncpg://admin:password@localhost:5432/echoesoftheashes" + engine = create_async_engine(url) + try: + async with engine.begin() as conn: + res = await conn.execute(text("SELECT id, location_id FROM characters WHERE name ILIKE 'Jocaru'")) + row = res.first() + if not row: + print("Jocaru not found.") + return + pid, loc = row[0], row[1] + print(f"Player Jocaru found (ID {pid}) at {loc}") + + await conn.execute(text("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid"), {"pid": pid}) + + items = [ + ('reinforced_pack', 1), + ('reinforced_bat', 1), + ('combat_knife', 1), + ('first_aid_kit', 10), + ('mystery_pills', 5), + ('energy_bar', 10) + ] + for iid, qty in items: + await conn.execute(text("INSERT INTO inventory (character_id, item_id, quantity) VALUES (:pid, :iid, :qty)"), {"pid": pid, "iid": iid, "qty": qty}) + + now = time.time() + despawn = now + 86400 + enemies = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2 + for eid in enemies: + await conn.execute(text("INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)"), + {"nid": eid, "loc": loc, "start": now, "end": despawn}) + print(f"Spawned {len(enemies)} enemies at {loc}.") + except Exception as e: + print(f"DB Error: {e}") + +asyncio.run(main()) diff --git a/setup_test_env.py b/setup_test_env.py new file mode 100644 index 0000000..46fd356 --- /dev/null +++ b/setup_test_env.py @@ -0,0 +1,100 @@ +import asyncio +import json +import time +import os +import random +from api.database import Database + +async def main(): + # 1. Update npcs.json to add a test boss + with open('gamedata/npcs.json', 'r') as f: + data = json.load(f) + if 'test_boss' not in data['npcs']: + data['npcs']['test_boss'] = { + "name": {"en": "Level 50 Test Boss", "es": "Jefe de Prueba Nivel 50"}, + "description": {"en": "A huge terrifying monster.", "es": "Un monstruo enorme y aterrador."}, + "emoji": "๐Ÿ‘น", + "hp_min": 1000, + "hp_max": 1500, + "damage_min": 25, + "damage_max": 45, + "defense": 15, + "xp_reward": 500, + "loot_table": [], + "flee_chance": 0.0, + "status_inflict_chance": 0.5, + "death_message": {"en": "The boss is defeated.", "es": "El jefe ha sido derrotado."} + } + with open('gamedata/npcs.json', 'w') as f: + json.dump(data, f, indent=2) + print("Added 'test_boss' to npcs.json") + + db = Database() + await db.connect() + + # 2. Get Jocaru + player = await db.fetch_one("SELECT * FROM characters WHERE name ILIKE 'Jocaru'") + if not player: + print("Player Jocaru not found!") + await db.disconnect() + return + + pid = player['id'] + ploc = player['location_id'] + + # 3. Give items + items_to_give = [ + ('reinforced_pack', 1), + ('reinforced_bat', 1), + ('knife', 1), + ('first_aid_kit', 10), + ('mystery_pills', 5), + ('energy_bar', 10), + ('molotov', 5) + ] + for item_id, qty in items_to_give: + for _ in range(qty): + from utils.game_helpers import generate_unique_item_stats + from api.items import ITEMS_MANAGER + item_def = ITEMS_MANAGER.get_item(item_id) + if hasattr(item_def, 'durability') and item_def.durability: + tier = item_def.tier if hasattr(item_def, 'tier') else 1 + stats = generate_unique_item_stats(item_id, item_def.durability, tier) + uid = await db.create_unique_item( + item_id=item_id, + tier=tier, + durability=stats['durability'], + max_durability=stats['durability'], + stats=json.dumps(stats.get('stats', {})) + ) + await db.execute( + "INSERT INTO inventory (character_id, item_id, quantity, unique_item_id) VALUES (:cid, :iid, 1, :uid)", + {"cid": pid, "iid": item_id, "uid": uid} + ) + else: + await db.execute( + "INSERT INTO inventory (character_id, item_id, quantity) VALUES (:cid, :iid, 1)", + {"cid": pid, "iid": item_id} + ) + print("Granted test items and backpack.") + + # 4. Give XP to reach lvl 50 if needed + await db.execute("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid", {"pid": pid}) + print("Buffed Jocaru to lvl 50 manually.") + + # 5. Spawn enemies at player's location + now = time.time() + despawn = now + 86400 # 1 day + + enemies_to_spawn = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2 + for eid in enemies_to_spawn: + await db.execute( + "INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)", + {"nid": eid, "loc": ploc, "start": now, "end": despawn} + ) + print(f"Spawned {len(enemies_to_spawn)} enemies at {ploc}") + + await db.disconnect() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/update_pvp.py b/update_pvp.py new file mode 100644 index 0000000..f991575 --- /dev/null +++ b/update_pvp.py @@ -0,0 +1,521 @@ +import sys +import re + +with open('/opt/dockers/echoes_of_the_ashes/api/routers/combat.py', 'r', encoding='utf-8') as f: + content = f.read() + +# 1. Tick Player Effects for PvP +effect_tick_code = """ + # Track the last action string for DB history + last_action_text = "" + + # Process status effects (bleeding, poison, etc.) before action + active_effects = await db.tick_player_effects(current_player['id']) + + if active_effects: + from ..game_logic import calculate_status_impact + total_impact = calculate_status_impact(active_effects) + + if total_impact > 0: + damage = total_impact + new_hp = max(0, current_player['hp'] - damage) + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + + messages.append(create_combat_message( + "effect_damage", + origin="player", + damage=damage, + effect_name="status effects" + )) + + if new_hp <= 0: + messages.append(create_combat_message("died", origin="player", message="You died from status effects!")) + combat_over = True + winner_id = opponent['id'] + + # Update current player to dead state + await db.update_player(current_player['id'], hp=0, is_dead=True) + + # Create corpse + import json + import time as time_module + inventory = await db.get_inventory(current_player['id']) + inventory_items = [] + for inv_item in inventory: + item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) + inventory_items.append({ + 'item_id': inv_item['item_id'], + 'name': item_def.name if item_def else inv_item['item_id'], + 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '๐Ÿ“ฆ', + 'quantity': inv_item['quantity'], + 'durability': inv_item.get('durability'), + 'max_durability': inv_item.get('max_durability'), + 'tier': inv_item.get('tier') + }) + + corpse_data = None + if inventory_items: + corpse_id = await db.create_player_corpse( + player_name=current_player['name'], + location_id=current_player['location_id'], + items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) + ) + await db.clear_inventory(current_player['id']) + corpse_data = { + "id": f"player_{corpse_id}", + "type": "player", + "name": f"{current_player['name']}'s Corpse", + "emoji": "โšฐ๏ธ", + "player_name": current_player['name'], + "loot_count": len(inventory_items), + "items": inventory_items, + "timestamp": time_module.time() + } + + # Update PvP statistics for both players + await db.update_player_statistics(current_player['id'], pvp_deaths=1, pvp_combats_lost=1, increment=True) + await db.update_player_statistics(opponent['id'], players_killed=1, pvp_combats_won=1, increment=True) + + # Broadcast corpse + broadcast_data = { + "message": get_game_message('pvp_defeat_broadcast', locale, opponent=current_player['name'], winner=opponent['name']), + "action": "player_died", + "player_id": current_player['id'] + } + if corpse_data: + broadcast_data["corpse"] = corpse_data + + await manager.send_to_location( + location_id=current_player['location_id'], + message={ + "type": "location_update", + "data": broadcast_data, + "timestamp": datetime.utcnow().isoformat() + } + ) + + await db.end_pvp_combat(pvp_combat['id']) + + elif total_impact < 0: + heal = abs(total_impact) + new_hp = min(current_player_stats.get('max_hp', current_player['max_hp']), current_player['hp'] + heal) + actual_heal = new_hp - current_player['hp'] + + if actual_heal > 0: + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + messages.append(create_combat_message( + "effect_heal", + origin="player", + heal=actual_heal, + effect_name="status effects" + )) + + # Stop processing action if player died from status effects + if not combat_over: +""" + +content = content.replace( + ' # Track the last action string for DB history\n last_action_text = ""', + effect_tick_code +) + +# Fix indentation of the actions block since it's now wrapped in `if not combat_over:` +import textwrap + +# 2. Add Skill and Use Item logic for PvP +skill_and_item_code = """ + elif req.action == 'skill': + if not req.item_id: + raise HTTPException(status_code=400, detail="skill_id (passed as item_id) required") + + from ..services.skills import skills_manager + skill_id = req.item_id + skill = skills_manager.get_skill(skill_id) + if not skill: + raise HTTPException(status_code=404, detail="Skill not found") + + # Check unlocked + stat_val = current_player.get(skill.stat_requirement, 0) + if stat_val < skill.stat_threshold or current_player['level'] < skill.level_requirement: + raise HTTPException(status_code=400, detail="Skill not unlocked") + + # Check cooldown + active_effects = await db.get_player_effects(current_player['id']) + cd_source = f"cd:{skill.id}" + for eff in active_effects: + if eff.get('source') == cd_source and eff.get('ticks_remaining', 0) > 0: + raise HTTPException(status_code=400, detail=f"Skill on cooldown ({eff['ticks_remaining']} turns)") + + # Check stamina + if current_player['stamina'] < skill.stamina_cost: + raise HTTPException(status_code=400, detail="Not enough stamina") + + # Deduct stamina + new_stamina = current_player['stamina'] - skill.stamina_cost + await db.update_player_stamina(current_player['id'], new_stamina) + current_player['stamina'] = new_stamina + + # Add cooldown effect + if skill.cooldown > 0: + await db.add_effect( + player_id=current_player['id'], + effect_name=f"{skill.id}_cooldown", + effect_icon="โณ", + effect_type="cooldown", + value=0, + ticks_remaining=skill.cooldown, + persist_after_combat=False, + source=cd_source + ) + + # Get weapon info + equipment = await db.get_all_equipment(current_player['id']) + weapon_damage = 0 + weapon_inv_id = None + inv_item = None + weapon_def = None + if equipment.get('weapon') and equipment['weapon']: + weapon_slot = equipment['weapon'] + inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id']) + if inv_item: + weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id']) + if weapon_def and weapon_def.stats: + weapon_damage = random.randint( + weapon_def.stats.get('damage_min', 0), + weapon_def.stats.get('damage_max', 0) + ) + weapon_inv_id = inv_item['id'] + + effects = skill.effects + new_opponent_hp = opponent['hp'] + damage_done = 0 + actual_damage = 0 + armor_absorbed = 0 + + # Damage skills + if 'damage_multiplier' in effects: + base_damage = 5 + strength_bonus = int(current_player['strength'] * 1.5) + level_bonus = current_player['level'] + variance = random.randint(-2, 2) + raw_damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) + + multiplier = effects['damage_multiplier'] + + if 'execute_threshold' in effects: + opponent_hp_pct = opponent['hp'] / opponent['max_hp'] if opponent['max_hp'] > 0 else 1 + if opponent_hp_pct <= effects['execute_threshold']: + multiplier = effects.get('execute_multiplier', multiplier) + + damage = max(1, int(raw_damage * multiplier)) + if effects.get('guaranteed_crit'): + damage = int(damage * 1.5) + + num_hits = effects.get('hits', 1) + + for hit in range(num_hits): + hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier)) + absorbed, broken_armor = await reduce_armor_durability(opponent['id'], hit_dmg) + armor_absorbed += absorbed + + for broken in broken_armor: + messages.append(create_combat_message( + "item_broken", + origin="enemy", + item_name=broken['name'], + emoji=broken['emoji'] + )) + last_action_text += f"\\n๐Ÿ’” {opponent['name']}'s {broken['emoji']} {broken['name']} broke!" + + actual_hit = max(1, hit_dmg - absorbed) + damage_done += actual_hit + new_opponent_hp = max(0, new_opponent_hp - actual_hit) + + actual_damage = damage_done + + messages.append(create_combat_message( + "skill_attack", + origin="player", + damage=damage_done, + skill_name=skill.name, + skill_icon=skill.icon, + hits=num_hits + )) + last_action_text = f"{current_player['name']} used {skill.name} on {opponent['name']} for {damage_done} damage! (Armor absorbed {armor_absorbed})" + + # Lifesteal + if 'lifesteal' in effects: + heal_amount = int(damage_done * effects['lifesteal']) + new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount) + if new_hp > current_player['hp']: + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + messages.append(create_combat_message( + "skill_heal", origin="player", heal=heal_amount, skill_icon="๐Ÿฉธ" + )) + + # Poison DoT + if 'poison_damage' in effects: + await db.add_effect( + player_id=opponent['id'], + effect_name="Poison", + effect_icon="๐Ÿงช", + effect_type="damage", + damage_per_tick=effects['poison_damage'], + ticks_remaining=effects['poison_duration'], + persist_after_combat=True, + source=f"skill_poison:{skill.id}" + ) + messages.append(create_combat_message( + "skill_effect", origin="player", message=f"๐Ÿงช Poisoned! ({effects['poison_damage']} dmg/turn)" + )) + + # Stun chance + if 'stun_chance' in effects and random.random() < effects['stun_chance']: + # Stun in PvP can be modeled as taking away a turn + await db.add_effect( + player_id=opponent['id'], + effect_name="Stunned", + effect_icon="๐Ÿ’ซ", + effect_type="debuff", + ticks_remaining=1, + persist_after_combat=False, + source="skill_stun" + ) + messages.append(create_combat_message( + "skill_effect", origin="player", message="๐Ÿ’ซ Stunned! (Currently skip effect)" + )) + + # Weapon durability + if weapon_inv_id and inv_item and inv_item.get('unique_item_id'): + new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1) + if new_durability is None: + messages.append(create_combat_message("weapon_broke", origin="player", item_name=weapon_def.name if weapon_def else "weapon")) + await db.unequip_item(current_player['id'], 'weapon') + + # Heal skills + if 'heal_percent' in effects: + heal_amount = int(current_player['max_hp'] * effects['heal_percent']) + new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount) + actual_heal = new_hp - current_player['hp'] + if actual_heal > 0: + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + messages.append(create_combat_message( + "skill_heal", origin="player", heal=actual_heal, skill_icon=skill.icon + )) + last_action_text = f"{current_player['name']} used {skill.name} and healed for {actual_heal} HP!" + + # Fortify + if 'armor_boost' in effects: + await db.add_effect( + player_id=current_player['id'], + effect_name="Fortify", + effect_icon="๐Ÿ›ก๏ธ", + effect_type="buff", + value=effects['armor_boost'], + ticks_remaining=effects['duration'], + persist_after_combat=False, + source=f"skill_fortify:{skill.id}" + ) + messages.append(create_combat_message( + "skill_effect", origin="player", message=f"๐Ÿ›ก๏ธ Fortified! (+{effects['armor_boost']} Armor)" + )) + last_action_text = f"{current_player['name']} used {skill.name} and bolstered their defenses!" + + # Process opponent HP if damage done + if damage_done > 0: + await db.update_player(opponent['id'], hp=new_opponent_hp) + if new_opponent_hp <= 0: + last_action_text += f"\\n๐Ÿ† {current_player['name']} has defeated {opponent['name']}!" + messages.append(create_combat_message("victory", origin="neutral", npc_name=opponent['name'])) + combat_over = True + winner_id = current_player['id'] + + await db.update_player(opponent['id'], hp=0, is_dead=True) + + # Create corpse + import json + import time as time_module + inventory = await db.get_inventory(opponent['id']) + inventory_items = [] + for inv_item in inventory: + item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) + inventory_items.append({ + 'item_id': inv_item['item_id'], + 'name': item_def.name if item_def else inv_item['item_id'], + 'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '๐Ÿ“ฆ', + 'quantity': inv_item['quantity'], + 'durability': inv_item.get('durability'), + 'max_durability': inv_item.get('max_durability'), + 'tier': inv_item.get('tier') + }) + + corpse_data = None + if inventory_items: + corpse_id = await db.create_player_corpse( + player_name=opponent['name'], + location_id=opponent['location_id'], + items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) + ) + await db.clear_inventory(opponent['id']) + corpse_data = { + "id": f"player_{corpse_id}", + "type": "player", + "name": f"{opponent['name']}'s Corpse", + "emoji": "โšฐ๏ธ", + "player_name": opponent['name'], + "loot_count": len(inventory_items), + "items": inventory_items, + "timestamp": time_module.time() + } + + # Update statistics + await db.update_player_statistics(opponent['id'], pvp_deaths=1, pvp_combats_lost=1, pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True) + await db.update_player_statistics(current_player['id'], players_killed=1, pvp_combats_won=1, pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True) + + # Broadcast corpse + broadcast_data = { + "message": get_game_message('pvp_defeat_broadcast', locale, opponent=opponent['name'], winner=current_player['name']), + "action": "player_died", + "player_id": opponent['id'] + } + if corpse_data: + broadcast_data["corpse"] = corpse_data + + await manager.send_to_location( + location_id=opponent['location_id'], + message={ + "type": "location_update", + "data": broadcast_data, + "timestamp": datetime.utcnow().isoformat() + } + ) + + await db.end_pvp_combat(pvp_combat['id']) + else: + await db.update_player_statistics(current_player['id'], pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True) + await db.update_player_statistics(opponent['id'], pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True) + + # End of turn swap + updates = { + 'turn': 'defender' if is_attacker else 'attacker', + 'turn_started_at': time.time(), + 'last_action': f"{last_action_text}|{time.time()}" + } + await db.update_pvp_combat(pvp_combat['id'], updates) + + else: + # Skill didn't do damage, but turn still ends + updates = { + 'turn': 'defender' if is_attacker else 'attacker', + 'turn_started_at': time.time(), + 'last_action': f"{last_action_text}|{time.time()}" + } + await db.update_pvp_combat(pvp_combat['id'], updates) + + elif req.action == 'use_item': + if not req.item_id: + raise HTTPException(status_code=400, detail="item_id required for use_item action") + + player_inventory = await db.get_inventory(current_player['id']) + inv_item = next((item for item in player_inventory if item['item_id'] == req.item_id), None) + + if not inv_item: + raise HTTPException(status_code=400, detail="Item not found in inventory") + + item_def = ITEMS_MANAGER.get_item(req.item_id) + if not item_def or not item_def.combat_usable: + raise HTTPException(status_code=400, detail="This item cannot be used in combat") + + item_name = get_locale_string(item_def.name, locale) + effects_applied = [] + + if item_def.effects.get('status_effect'): + status_data = item_def.effects['status_effect'] + await db.add_effect( + player_id=current_player['id'], + effect_name=status_data['name'], + effect_icon=status_data.get('icon', 'โœจ'), + effect_type=status_data.get('type', 'buff'), + damage_per_tick=status_data.get('damage_per_tick', 0), + value=status_data.get('value', 0), + ticks_remaining=status_data.get('ticks', 3), + persist_after_combat=True, + source=f"item:{item_def.id}" + ) + effects_applied.append(f"Applied {status_data['name']}") + + if item_def.effects.get('cures'): + for cure_effect in item_def.effects['cures']: + if await db.remove_effect(current_player['id'], cure_effect): + effects_applied.append(f"Cured {cure_effect}") + + if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0: + item_effectiveness = current_player_stats.get('item_effectiveness', 1.0) + restore_amount = int(item_def.effects['hp_restore'] * item_effectiveness) + new_hp = min(current_player['max_hp'], current_player['hp'] + restore_amount) + actual_heal = new_hp - current_player['hp'] + if actual_heal > 0: + await db.update_player(current_player['id'], hp=new_hp) + current_player['hp'] = new_hp + effects_applied.append(get_game_message('healed_amount_text', locale, amount=actual_heal)) + messages.append(create_combat_message("item_heal", origin="player", heal=actual_heal)) + + if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0: + item_effectiveness = current_player_stats.get('item_effectiveness', 1.0) + restore_amount = int(item_def.effects['stamina_restore'] * item_effectiveness) + new_stamina = min(current_player['max_stamina'], current_player['stamina'] + restore_amount) + actual_restore = new_stamina - current_player['stamina'] + if actual_restore > 0: + await db.update_player_stamina(current_player['id'], new_stamina) + effects_applied.append(f"Restored {actual_restore} stamina") + messages.append(create_combat_message("item_restore", origin="player", stat="stamina", amount=actual_restore)) + + if inv_item['quantity'] > 1: + await db.update_inventory_quantity(inv_item['id'], inv_item['quantity'] - 1) + else: + await db.remove_from_inventory(inv_item['id']) + + messages.append(create_combat_message( + "use_item", origin="player", item_name=item_name, + message=get_game_message('used_item_text', locale, name=item_name) + " (" + ", ".join(effects_applied) + ")" + )) + last_action_text = f"{current_player['name']} used {item_name}!" + + # End of turn swap + updates = { + 'turn': 'defender' if is_attacker else 'attacker', + 'turn_started_at': time.time(), + 'last_action': f"{last_action_text}|{time.time()}" + } + await db.update_pvp_combat(pvp_combat['id'], updates) +""" + +content = content.replace( + " elif req.action == 'flee':", + skill_and_item_code + "\n elif req.action == 'flee':" +) + +# Indent the blocks bounded by the combat_over condition +lines = content.split('\\n') +inside_combat_over = False +new_lines = [] + +for line in lines: + new_lines.append(line) + +# Since doing line manipulation might be tricky via replace string, +# we need to be very precise. We will apply the wrapper around attack and flee logic inside the Python script by using Regex. +# Well, wait, I can just write out the fully rewritten function or use `re` substitution for indenting. +# It is simpler to just ensure all attack and flee actions are under `if not combat_over:` +# by indenting the whole block manually in the script. + +try: + with open('/opt/dockers/echoes_of_the_ashes/api/routers/combat.py', 'w', encoding='utf-8') as f: + f.write(content) + print("Script ran. Patched combat.py.") +except Exception as e: + print(e)