From 185781d16822bb7f42d42f4dadbecf205e85a803 Mon Sep 17 00:00:00 2001 From: Joan Date: Wed, 25 Feb 2026 10:05:14 +0100 Subject: [PATCH] feat(backend): Implement base framework for Perks, Skills, and Derived Stats --- api/services/models.py | 3 +- api/services/skills.py | 178 ++++++++++++++++++++++ api/services/stats.py | 225 ++++++++++++++++++++++++++++ gamedata/perks.json | 194 ++++++++++++++++++++++++ gamedata/skills.json | 332 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 931 insertions(+), 1 deletion(-) create mode 100644 api/services/skills.py create mode 100644 api/services/stats.py create mode 100644 gamedata/perks.json create mode 100644 gamedata/skills.json diff --git a/api/services/models.py b/api/services/models.py index 8729940..9564876 100644 --- a/api/services/models.py +++ b/api/services/models.py @@ -79,8 +79,9 @@ class InitiateCombatRequest(BaseModel): class CombatActionRequest(BaseModel): - action: str # 'attack', 'defend', 'flee', 'use_item' + action: str # 'attack', 'skill', 'flee', 'use_item' item_id: Optional[str] = None # For use_item action + skill_id: Optional[str] = None # For skill action class PvPCombatInitiateRequest(BaseModel): diff --git a/api/services/skills.py b/api/services/skills.py new file mode 100644 index 0000000..a48210b --- /dev/null +++ b/api/services/skills.py @@ -0,0 +1,178 @@ +""" +Skills service - loads skill definitions and provides skill availability logic. +""" +import json +from typing import Dict, Any, List, Optional +from pathlib import Path + + +class Skill: + """Represents a combat skill.""" + def __init__(self, skill_id: str, data: Dict[str, Any]): + self.id = skill_id + self.name = data.get('name', skill_id) + self.description = data.get('description', '') + self.icon = data.get('icon', '⚔️') + self.stat_requirement = data.get('stat_requirement', 'strength') + self.stat_threshold = data.get('stat_threshold', 0) + self.level_requirement = data.get('level_requirement', 1) + self.cooldown = data.get('cooldown', 3) + self.stamina_cost = data.get('stamina_cost', 5) + self.effects = data.get('effects', {}) + + +class SkillsManager: + """Loads and manages skill definitions from JSON.""" + + def __init__(self, gamedata_path: str = "./gamedata"): + self.gamedata_path = Path(gamedata_path) + self.skills: Dict[str, Skill] = {} + self.load_skills() + + def load_skills(self): + """Load skills from skills.json.""" + json_path = self.gamedata_path / 'skills.json' + try: + with open(json_path, 'r') as f: + data = json.load(f) + + for skill_id, skill_data in data.get('skills', {}).items(): + self.skills[skill_id] = Skill(skill_id, skill_data) + + print(f"⚔️ Loaded {len(self.skills)} skills") + except FileNotFoundError: + print("⚠️ skills.json not found") + except Exception as e: + print(f"⚠️ Error loading skills.json: {e}") + + def get_skill(self, skill_id: str) -> Optional[Skill]: + """Get a skill by ID.""" + return self.skills.get(skill_id) + + def get_all_skills(self) -> Dict[str, Skill]: + """Get all skills.""" + return self.skills + + def get_available_skills(self, character: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Get all skills available to a character based on their stats and level. + Returns list of skill dicts with availability info. + """ + available = [] + for skill_id, skill in self.skills.items(): + stat_value = character.get(skill.stat_requirement, 0) + level = character.get('level', 1) + + unlocked = (stat_value >= skill.stat_threshold and level >= skill.level_requirement) + + skill_info = { + "id": skill.id, + "name": skill.name, + "description": skill.description, + "icon": skill.icon, + "stat_requirement": skill.stat_requirement, + "stat_threshold": skill.stat_threshold, + "level_requirement": skill.level_requirement, + "cooldown": skill.cooldown, + "stamina_cost": skill.stamina_cost, + "unlocked": unlocked, + "effects": skill.effects, + } + available.append(skill_info) + + return available + + +class Perk: + """Represents a passive perk.""" + def __init__(self, perk_id: str, data: Dict[str, Any]): + self.id = perk_id + self.name = data.get('name', perk_id) + self.description = data.get('description', '') + self.icon = data.get('icon', '⭐') + self.requirements = data.get('requirements', {}) + self.effects = data.get('effects', {}) + + +class PerksManager: + """Loads and manages perk definitions from JSON.""" + + def __init__(self, gamedata_path: str = "./gamedata"): + self.gamedata_path = Path(gamedata_path) + self.perks: Dict[str, Perk] = {} + self.load_perks() + + def load_perks(self): + """Load perks from perks.json.""" + json_path = self.gamedata_path / 'perks.json' + try: + with open(json_path, 'r') as f: + data = json.load(f) + + for perk_id, perk_data in data.get('perks', {}).items(): + self.perks[perk_id] = Perk(perk_id, perk_data) + + print(f"⭐ Loaded {len(self.perks)} perks") + except FileNotFoundError: + print("⚠️ perks.json not found") + except Exception as e: + print(f"⚠️ Error loading perks.json: {e}") + + def get_perk(self, perk_id: str) -> Optional[Perk]: + """Get a perk by ID.""" + return self.perks.get(perk_id) + + def get_all_perks(self) -> Dict[str, Perk]: + """Get all perks.""" + return self.perks + + def check_requirements(self, perk: Perk, character: Dict[str, Any]) -> bool: + """Check if a character meets a perk's requirements.""" + for req_key, req_value in perk.requirements.items(): + if req_key.endswith('_max'): + # Max constraint (e.g., endurance_max: 8 means END must be ≤ 8) + stat_name = req_key.replace('_max', '') + if character.get(stat_name, 0) > req_value: + return False + else: + # Min constraint + if character.get(req_key, 0) < req_value: + return False + return True + + def get_available_perks(self, character: Dict[str, Any], owned_perk_ids: List[str]) -> List[Dict[str, Any]]: + """ + Get all perks with availability status for a character. + """ + available = [] + for perk_id, perk in self.perks.items(): + meets_requirements = self.check_requirements(perk, character) + owned = perk_id in owned_perk_ids + + perk_info = { + "id": perk.id, + "name": perk.name, + "description": perk.description, + "icon": perk.icon, + "requirements": perk.requirements, + "effects": perk.effects, + "meets_requirements": meets_requirements, + "owned": owned, + } + available.append(perk_info) + + return available + + +# Perk points per level +PERK_POINT_INTERVAL = 5 # Every 5 levels + + +def get_total_perk_points(level: int) -> int: + """Calculate total perk points available for a given level.""" + return level // PERK_POINT_INTERVAL + + +# Global instances +skills_manager = SkillsManager() +perks_manager = PerksManager() diff --git a/api/services/stats.py b/api/services/stats.py new file mode 100644 index 0000000..d4fd6c8 --- /dev/null +++ b/api/services/stats.py @@ -0,0 +1,225 @@ +""" +Central stat calculation service. +All derived stats are computed here from base attributes + equipment + buffs. +Results are cached in Redis and invalidated on any change. +""" +import json +from typing import Dict, Any, Optional, List +from .. import database as db +from ..items import items_manager as ITEMS_MANAGER + + +# ─── Game Config (raise per expansion) ─── +STAT_CAP = 50 # Max base stat points per attribute +MAX_LEVEL = 60 # Max character level +POINTS_PER_LEVEL = 1 # Stat points granted per level + + +async def calculate_derived_stats(character_id: int, redis_mgr=None) -> Dict[str, Any]: + """ + Calculate all derived stats for a character. + Checks Redis cache first; if miss, computes from DB and caches result. + + Returns dict with all derived stat values. + """ + # 1. Check Redis cache + if redis_mgr and redis_mgr.redis_client: + try: + cached = await redis_mgr.redis_client.get(f"stats:{character_id}") + if cached: + return json.loads(cached) + except Exception: + pass # Graceful degradation — recalculate if Redis fails + + # 2. Fetch data from DB + char = await db.get_player_by_id(character_id) + if not char: + return _empty_stats() + + equipment = await db.get_all_equipment(character_id) + effects = await db.get_player_effects(character_id) + + # 3. Fetch owned perks + owned_perks = await db.get_character_perks(character_id) + owned_perk_ids = [row['perk_id'] for row in owned_perks] + + # 4. Compute derived stats + stats = _compute_stats(char, equipment, effects, owned_perk_ids) + + # 5. Cache in Redis (5 min TTL) + if redis_mgr and redis_mgr.redis_client: + try: + await redis_mgr.redis_client.setex( + f"stats:{character_id}", 300, json.dumps(stats) + ) + except Exception: + pass + + return stats + + +def _compute_stats(char: Dict[str, Any], equipment: Dict[str, Any], effects: List[Dict], perk_ids: List[str] = None) -> Dict[str, Any]: + """Pure computation of derived stats from base data.""" + strength = char.get('strength', 0) + agility = char.get('agility', 0) + endurance = char.get('endurance', 0) + intellect = char.get('intellect', 0) + level = char.get('level', 1) + if perk_ids is None: + perk_ids = [] + + # ─── Base derived stats from attributes ─── + attack_power = 5 + int(strength * 1.5) + level + crit_chance = 0.05 + (agility * 0.005) + crit_damage = 1.5 + (strength * 0.01) + dodge_chance = min(0.25, 0.02 + (agility * 0.005)) # Cap 25% + flee_chance_base = 0.4 + (agility * 0.01) + max_hp = 30 + (endurance * 5) + (level * 3) + max_stamina = 20 + (endurance * 2) + level + status_resistance = endurance * 0.01 + block_chance = 0.0 + item_effectiveness = 1.0 + (intellect * 0.02) + xp_bonus = 1.0 + (intellect * 0.01) + loot_quality = 1.0 + (intellect * 0.005) + crafting_bonus = intellect * 0.01 + carry_weight = 10.0 + (strength * 0.5) + + # ─── Equipment bonuses ─── + total_armor = 0 + weapon_crit = 0.0 + weapon_damage_min = 0 + weapon_damage_max = 0 + has_shield = False + + for slot, item_data in equipment.items(): + 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', '')) + + # Try to get item_id from the inventory item if the direct lookup failed + if not item_def: + continue + + if item_def.stats: + total_armor += item_def.stats.get('armor', 0) + weapon_crit += item_def.stats.get('crit_chance', 0) + + if slot == 'weapon': + weapon_damage_min = item_def.stats.get('damage_min', 0) + weapon_damage_max = item_def.stats.get('damage_max', 0) + + if slot == 'offhand': + has_shield = True + + # Apply equipment to derived stats + crit_chance += weapon_crit + armor_reduction = total_armor / (total_armor + 50) if total_armor > 0 else 0.0 + + if has_shield: + block_chance = strength * 0.003 + + # ─── Buff effects ─── + for effect in effects: + effect_name = effect.get('effect_name', '') + value = effect.get('value', 0) + # Future: apply buff modifiers here + + # ─── Perk passive bonuses ─── + if 'thick_skin' in perk_ids: + max_hp = int(max_hp * 1.10) # +10% max HP + if 'lucky_strike' in perk_ids: + crit_chance += 0.05 # +5% crit chance + if 'quick_learner' in perk_ids: + xp_bonus *= 1.15 # +15% XP + if 'glass_cannon' in perk_ids: + attack_power = int(attack_power * 1.30) # +30% attack + max_hp = int(max_hp * 0.80) # -20% HP + if 'survivor' in perk_ids: + max_hp = int(max_hp * 1.02) # Small HP boost from regen perk + if 'scavenger' in perk_ids: + loot_quality *= 1.10 # +10% loot quality + if 'fleet_footed' in perk_ids: + # Travel stamina reduction tracked for movement system + pass + + stats = { + # Core combat + "attack_power": attack_power, + "crit_chance": round(crit_chance, 4), + "crit_damage": round(crit_damage, 2), + "dodge_chance": round(dodge_chance, 4), + "flee_chance_base": round(flee_chance_base, 2), + # Vitals + "max_hp": max_hp, + "max_stamina": max_stamina, + # Defense + "total_armor": total_armor, + "armor_reduction": round(armor_reduction, 4), + "block_chance": round(block_chance, 4), + "status_resistance": round(status_resistance, 4), + # Utility + "item_effectiveness": round(item_effectiveness, 2), + "xp_bonus": round(xp_bonus, 2), + "loot_quality": round(loot_quality, 3), + "crafting_bonus": round(crafting_bonus, 2), + "carry_weight": round(carry_weight, 1), + # Weapon info + "weapon_damage_min": weapon_damage_min, + "weapon_damage_max": weapon_damage_max, + "has_shield": has_shield, + # Perk flags + "has_last_stand": 'last_stand' in perk_ids, + "has_resilient": 'resilient' in perk_ids, + "has_iron_fist": 'iron_fist' in perk_ids, + "has_heavy_hitter": 'heavy_hitter' in perk_ids, + } + + return stats + + +def _empty_stats() -> Dict[str, Any]: + """Default stats for error cases.""" + return { + "attack_power": 5, + "crit_chance": 0.05, + "crit_damage": 1.5, + "dodge_chance": 0.02, + "flee_chance_base": 0.4, + "max_hp": 30, + "max_stamina": 20, + "total_armor": 0, + "armor_reduction": 0.0, + "block_chance": 0.0, + "status_resistance": 0.0, + "item_effectiveness": 1.0, + "xp_bonus": 1.0, + "loot_quality": 1.0, + "crafting_bonus": 0.0, + "carry_weight": 10.0, + "weapon_damage_min": 0, + "weapon_damage_max": 0, + "has_shield": False, + } + + +async def invalidate_stats_cache(character_id: int, redis_mgr=None): + """ + Delete cached stats for a character. Call this whenever: + - Equipment changes (equip/unequip/break) + - Stat points allocated + - Level up + - Buff applied/expired + """ + if redis_mgr and redis_mgr.redis_client: + try: + await redis_mgr.redis_client.delete(f"stats:{character_id}") + except Exception: + pass + + +def get_flee_chance(flee_chance_base: float, enemy_level: int) -> float: + """Calculate actual flee chance against a specific enemy.""" + return max(0.1, min(0.9, flee_chance_base - (enemy_level * 0.02))) diff --git a/gamedata/perks.json b/gamedata/perks.json new file mode 100644 index 0000000..ee091a0 --- /dev/null +++ b/gamedata/perks.json @@ -0,0 +1,194 @@ +{ + "perks": { + "heavy_hitter": { + "name": { + "en": "Heavy Hitter", + "es": "Golpe Pesado" + }, + "description": { + "en": "+10% damage with two-handed weapons", + "es": "+10% de daño con armas a dos manos" + }, + "icon": "🔨", + "requirements": { + "strength": 10 + }, + "effects": { + "two_handed_damage_bonus": 0.1 + } + }, + "iron_fist": { + "name": { + "en": "Iron Fist", + "es": "Puño de Hierro" + }, + "description": { + "en": "Unarmed attacks deal STR × 1 damage", + "es": "Los ataques sin armas hacen STR × 1 de daño" + }, + "icon": "👊", + "requirements": { + "strength": 20 + }, + "effects": { + "unarmed_str_scaling": 1.0 + } + }, + "fleet_footed": { + "name": { + "en": "Fleet Footed", + "es": "Pies Ligeros" + }, + "description": { + "en": "-20% stamina cost on travel", + "es": "-20% de coste de aguante al viajar" + }, + "icon": "🏃", + "requirements": { + "agility": 10 + }, + "effects": { + "travel_stamina_reduction": 0.2 + } + }, + "lucky_strike": { + "name": { + "en": "Lucky Strike", + "es": "Golpe de Suerte" + }, + "description": { + "en": "+5% crit chance", + "es": "+5% de probabilidad de crítico" + }, + "icon": "🍀", + "requirements": { + "agility": 20 + }, + "effects": { + "crit_chance_bonus": 0.05 + } + }, + "thick_skin": { + "name": { + "en": "Thick Skin", + "es": "Piel Gruesa" + }, + "description": { + "en": "+10% max HP", + "es": "+10% de vida máxima" + }, + "icon": "🛡️", + "requirements": { + "endurance": 10 + }, + "effects": { + "max_hp_bonus_percent": 0.1 + } + }, + "resilient": { + "name": { + "en": "Resilient", + "es": "Resistente" + }, + "description": { + "en": "Status effects last 1 fewer turn (min 1)", + "es": "Los efectos de estado duran 1 turno menos (mín 1)" + }, + "icon": "💪", + "requirements": { + "endurance": 20 + }, + "effects": { + "status_duration_reduction": 1 + } + }, + "quick_learner": { + "name": { + "en": "Quick Learner", + "es": "Aprendiz Rápido" + }, + "description": { + "en": "+15% XP gain", + "es": "+15% de experiencia ganada" + }, + "icon": "📖", + "requirements": { + "intellect": 10 + }, + "effects": { + "xp_bonus": 0.15 + } + }, + "scavenger": { + "name": { + "en": "Scavenger", + "es": "Carroñero" + }, + "description": { + "en": "+1 quantity on consumable/resource drops", + "es": "+1 cantidad en drops de consumibles/recursos" + }, + "icon": "🦅", + "requirements": { + "intellect": 20 + }, + "effects": { + "consumable_loot_bonus": 1 + } + }, + "survivor": { + "name": { + "en": "Survivor", + "es": "Superviviente" + }, + "description": { + "en": "Heal 2% max HP every combat turn", + "es": "Cura 2% de vida máxima cada turno de combate" + }, + "icon": "❤️‍🩹", + "requirements": { + "endurance": 15, + "agility": 10 + }, + "effects": { + "combat_regen_percent": 0.02 + } + }, + "glass_cannon": { + "name": { + "en": "Glass Cannon", + "es": "Cañón de Cristal" + }, + "description": { + "en": "+30% damage, -20% max HP", + "es": "+30% de daño, -20% de vida máxima" + }, + "icon": "💣", + "requirements": { + "strength": 20, + "endurance_max": 8 + }, + "effects": { + "damage_bonus": 0.3, + "max_hp_penalty_percent": 0.2 + } + }, + "last_stand": { + "name": { + "en": "Last Stand", + "es": "Última Resistencia" + }, + "description": { + "en": "Once per combat, survive lethal damage with 1 HP", + "es": "Una vez por combate, sobrevive daño letal con 1 de vida" + }, + "icon": "💀", + "requirements": { + "endurance": 30 + }, + "effects": { + "cheat_death": true + } + } + } +} \ No newline at end of file diff --git a/gamedata/skills.json b/gamedata/skills.json new file mode 100644 index 0000000..88292ab --- /dev/null +++ b/gamedata/skills.json @@ -0,0 +1,332 @@ +{ + "skills": { + "power_strike": { + "name": { + "en": "Power Strike", + "es": "Golpe Poderoso" + }, + "description": { + "en": "A devastating blow with 20% chance to stun", + "es": "Un golpe devastador con 20% de probabilidad de aturdir" + }, + "icon": "💥", + "stat_requirement": "strength", + "stat_threshold": 8, + "level_requirement": 5, + "cooldown": 3, + "stamina_cost": 5, + "effects": { + "damage_multiplier": 1.8, + "stun_chance": 0.2, + "stun_duration": 1 + } + }, + "crushing_blow": { + "name": { + "en": "Crushing Blow", + "es": "Golpe Aplastante" + }, + "description": { + "en": "Heavy strike that ignores 50% of enemy armor", + "es": "Golpe pesado que ignora el 50% de la armadura enemiga" + }, + "icon": "🔨", + "stat_requirement": "strength", + "stat_threshold": 15, + "level_requirement": 12, + "cooldown": 4, + "stamina_cost": 7, + "effects": { + "damage_multiplier": 1.5, + "armor_penetration": 0.5 + } + }, + "berserker_rage": { + "name": { + "en": "Berserker Rage", + "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" + }, + "icon": "🔥", + "stat_requirement": "strength", + "stat_threshold": 25, + "level_requirement": 20, + "cooldown": 6, + "stamina_cost": 10, + "effects": { + "buff": "berserker_rage", + "buff_duration": 3, + "damage_bonus": 0.5, + "damage_taken_increase": 0.25 + } + }, + "execution": { + "name": { + "en": "Execution", + "es": "Ejecución" + }, + "description": { + "en": "300% damage if target below 25% HP, else 100%", + "es": "300% de daño si el objetivo está por debajo del 25% de vida, si no 100%" + }, + "icon": "⚰️", + "stat_requirement": "strength", + "stat_threshold": 40, + "level_requirement": 35, + "cooldown": 8, + "stamina_cost": 8, + "effects": { + "damage_multiplier": 1.0, + "execute_threshold": 0.25, + "execute_multiplier": 3.0 + } + }, + "quick_slash": { + "name": { + "en": "Quick Slash", + "es": "Tajo Rápido" + }, + "description": { + "en": "Attack twice at 60% power each", + "es": "Ataca dos veces al 60% de poder cada una" + }, + "icon": "🗡️", + "stat_requirement": "agility", + "stat_threshold": 8, + "level_requirement": 5, + "cooldown": 2, + "stamina_cost": 3, + "effects": { + "hits": 2, + "damage_multiplier": 0.6 + } + }, + "evade": { + "name": { + "en": "Evade", + "es": "Evadir" + }, + "description": { + "en": "Guaranteed dodge on the next incoming attack", + "es": "Esquivar garantizado en el próximo ataque recibido" + }, + "icon": "🏃", + "stat_requirement": "agility", + "stat_threshold": 15, + "level_requirement": 12, + "cooldown": 3, + "stamina_cost": 4, + "effects": { + "buff": "evade", + "buff_duration": 1, + "guaranteed_dodge": true + } + }, + "poisoned_blade": { + "name": { + "en": "Poisoned Blade", + "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)" + }, + "icon": "🧪", + "stat_requirement": "agility", + "stat_threshold": 25, + "level_requirement": 20, + "cooldown": 5, + "stamina_cost": 6, + "effects": { + "damage_multiplier": 0.8, + "poison_damage": 3, + "poison_duration": 4 + } + }, + "shadow_strike": { + "name": { + "en": "Shadow Strike", + "es": "Golpe Sombrío" + }, + "description": { + "en": "250% damage, guaranteed critical hit", + "es": "250% de daño, golpe crítico garantizado" + }, + "icon": "🌑", + "stat_requirement": "agility", + "stat_threshold": 40, + "level_requirement": 35, + "cooldown": 7, + "stamina_cost": 8, + "effects": { + "damage_multiplier": 2.5, + "guaranteed_crit": true + } + }, + "fortify": { + "name": { + "en": "Fortify", + "es": "Fortificar" + }, + "description": { + "en": "Reduce incoming damage by 60% for 2 turns", + "es": "Reduce el daño recibido en un 60% durante 2 turnos" + }, + "icon": "🛡️", + "stat_requirement": "endurance", + "stat_threshold": 8, + "level_requirement": 5, + "cooldown": 3, + "stamina_cost": 4, + "effects": { + "buff": "fortify", + "buff_duration": 2, + "damage_reduction": 0.6 + } + }, + "second_wind": { + "name": { + "en": "Second Wind", + "es": "Segundo Aliento" + }, + "description": { + "en": "Restore 20% of max HP", + "es": "Restaura el 20% de la vida máxima" + }, + "icon": "💚", + "stat_requirement": "endurance", + "stat_threshold": 15, + "level_requirement": 12, + "cooldown": 5, + "stamina_cost": 6, + "effects": { + "heal_percent": 0.2 + } + }, + "iron_skin": { + "name": { + "en": "Iron Skin", + "es": "Piel de Hierro" + }, + "description": { + "en": "Immune to status effects for 3 turns", + "es": "Inmune a efectos de estado durante 3 turnos" + }, + "icon": "🪨", + "stat_requirement": "endurance", + "stat_threshold": 25, + "level_requirement": 20, + "cooldown": 6, + "stamina_cost": 8, + "effects": { + "buff": "iron_skin", + "buff_duration": 3, + "status_immunity": true + } + }, + "adrenaline_rush": { + "name": { + "en": "Adrenaline Rush", + "es": "Subida de Adrenalina" + }, + "description": { + "en": "Restore 30% of max stamina (free to use)", + "es": "Restaura el 30% del aguante máximo (sin coste)" + }, + "icon": "⚡", + "stat_requirement": "endurance", + "stat_threshold": 40, + "level_requirement": 35, + "cooldown": 8, + "stamina_cost": 0, + "effects": { + "stamina_restore_percent": 0.3 + } + }, + "analyze": { + "name": { + "en": "Analyze", + "es": "Analizar" + }, + "description": { + "en": "Reveal enemy HP%, next attack, and weakness", + "es": "Revela el % de vida del enemigo, su próximo ataque y debilidad" + }, + "icon": "🔍", + "stat_requirement": "intellect", + "stat_threshold": 8, + "level_requirement": 5, + "cooldown": 2, + "stamina_cost": 2, + "effects": { + "reveal_hp": true, + "reveal_intent": true, + "mark_analyzed": true + } + }, + "exploit_weakness": { + "name": { + "en": "Exploit Weakness", + "es": "Explotar Debilidad" + }, + "description": { + "en": "200% damage if Analyze was used this combat", + "es": "200% de daño si se usó Analizar en este combate" + }, + "icon": "🎯", + "stat_requirement": "intellect", + "stat_threshold": 15, + "level_requirement": 12, + "cooldown": 4, + "stamina_cost": 5, + "effects": { + "damage_multiplier": 2.0, + "requires_analyzed": true + } + }, + "drain_life": { + "name": { + "en": "Drain Life", + "es": "Drenar Vida" + }, + "description": { + "en": "100% damage, heal for 50% of damage dealt", + "es": "100% de daño, cura el 50% del daño causado" + }, + "icon": "🩸", + "stat_requirement": "intellect", + "stat_threshold": 25, + "level_requirement": 20, + "cooldown": 5, + "stamina_cost": 6, + "effects": { + "damage_multiplier": 1.0, + "lifesteal": 0.5 + } + }, + "foresight": { + "name": { + "en": "Foresight", + "es": "Premonición" + }, + "description": { + "en": "Enemy's next 2 attacks automatically miss", + "es": "Los próximos 2 ataques del enemigo fallan automáticamente" + }, + "icon": "👁️", + "stat_requirement": "intellect", + "stat_threshold": 40, + "level_requirement": 35, + "cooldown": 7, + "stamina_cost": 7, + "effects": { + "buff": "foresight", + "buff_duration": 2, + "enemy_miss": true + } + } + } +} \ No newline at end of file