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

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

View File

@@ -599,29 +599,85 @@ def generate_npc_intent(npc_def, combat_state: dict) -> dict:
Generate the NEXT intent for an NPC.
Returns a dict with intent type and details.
"""
# Default intent is attack
intent = {"type": "attack", "value": 0}
import random
from api.services.skills import skills_manager
# Logic could be more complex based on NPC type, HP, etc.
roll = random.random()
npc_hp_pct = combat_state['npc_hp'] / combat_state['npc_max_hp'] if combat_state['npc_max_hp'] > 0 else 0
skills = getattr(npc_def, 'skills', [])
# 20% chance to defend if HP < 50%
if (combat_state['npc_hp'] / combat_state['npc_max_hp'] < 0.5) and roll < 0.2:
intent = {"type": "defend", "value": 0}
# 15% chance for special attack (if defined, otherwise strong attack)
elif roll < 0.35:
intent = {"type": "special", "value": 0}
active_effects = combat_state.get('npc_status_effects', '')
cooldowns = {}
if active_effects:
for eff in active_effects.split('|'):
if eff.startswith('cd_'):
parts = eff.split(':')
if len(parts) >= 2:
cooldowns[parts[0][3:]] = int(parts[1])
available_skills = []
has_heal = None
has_buff = None
damage_skills = []
for skill_id in skills:
if cooldowns.get(skill_id, 0) > 0:
continue
skill = skills_manager.get_skill(skill_id)
if not skill: continue
available_skills.append(skill)
if 'heal_percent' in skill.effects:
has_heal = skill
elif 'buff' in skill.effects:
has_buff = skill
else:
intent = {"type": "attack", "value": 0}
damage_skills.append(skill)
return intent
# 1. Survival First
if has_heal and npc_hp_pct < 0.3:
if random.random() < 0.8:
return {"type": "skill", "value": has_heal.id}
# 2. Buffs
if has_buff:
buff_name = has_buff.effects['buff']
is_buff_active = False
if active_effects:
for eff in active_effects.split('|'):
if eff.startswith(buff_name + ':'):
is_buff_active = True
break
if not is_buff_active and random.random() < 0.6:
return {"type": "skill", "value": has_buff.id}
# 3. Telegraphed Attack Check (15% chance if health > 30%)
if npc_hp_pct > 0.3 and random.random() < 0.15:
return {"type": "charge", "value": "charging_attack"}
# 4. Damage Skills
if damage_skills and random.random() < 0.4:
chosen = random.choice(damage_skills)
return {"type": "skill", "value": chosen.id}
# Default to attack or defend (legacy logic)
roll = random.random()
if npc_hp_pct < 0.5 and roll < 0.1:
return {"type": "defend", "value": 0}
return {"type": "attack", "value": 0}
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, player_stats: dict = None) -> Tuple[List[dict], bool]:
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, player_stats: dict = None, locale: str = 'en') -> Tuple[List[dict], bool]:
"""
Execute NPC turn based on PREVIOUS intent, then generate NEXT intent.
Returns: (messages_list, player_defeated)
"""
import random
import time
from api import database as db
from api.services.helpers import create_combat_message, get_game_message, get_locale_string
from api.services.skills import skills_manager
player = await db.get_player_by_id(player_id)
if not player:
return [], True
@@ -635,13 +691,10 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p
is_stunned = False
if npc_status_str:
# Parse status: "bleeding:5:3" (name:dmg:ticks) or "stun:1"
# Handling multiple effects separated by |
effects_list = npc_status_str.split('|')
active_effects = []
npc_damage_taken = 0
npc_healing_received = 0
is_stunned = False
for effect_str in effects_list:
if not effect_str: continue
@@ -656,18 +709,24 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p
messages.append(create_combat_message(
"skill_effect",
origin="enemy",
message=f"💫 {npc_def.name} is stunned and cannot act!"
message=get_game_message('npc_stunned_cannot_act', locale, npc_name=get_locale_string(npc_def.name, locale))
))
ticks -= 1
if ticks > 0:
active_effects.append(f"stun:{ticks}")
continue
if name.startswith('cd_') and len(parts) >= 3:
ticks = int(parts[2])
ticks -= 1
if ticks > 0:
active_effects.append(f"{name}:{parts[1]}:{ticks}")
continue
if len(parts) >= 3:
dmg = int(parts[1])
ticks = int(parts[2])
# Apply effect
if ticks > 0:
if dmg > 0:
npc_damage_taken += dmg
@@ -682,205 +741,158 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p
heal = abs(dmg)
npc_healing_received += heal
messages.append(create_combat_message(
"effect_heal", # Check if this message type exists or fallback
"effect_heal",
origin="enemy",
heal=heal,
effect_name=name,
npc_name=npc_def.name
))
elif name in ["berserker_rage", "fortify", "analyzed"]:
pass
# Decrement tick
ticks -= 1
if ticks > 0:
active_effects.append(f"{name}:{dmg}:{ticks}")
except Exception as e:
print(f"Error parsing NPC status: {e}")
# Update NPC active effects
new_status_str = "|".join(active_effects)
if new_status_str != npc_status_str:
await db.update_combat(player_id, {'npc_status_effects': new_status_str})
# Apply Total Damage/Healing
if npc_damage_taken > 0:
npc_hp = max(0, npc_hp - npc_damage_taken)
if npc_healing_received > 0:
npc_hp = min(npc_max_hp, npc_hp + npc_healing_received)
# Update NPC HP in DB
await db.update_combat(player_id, {'npc_hp': npc_hp})
# Check if NPC died from effects
if npc_hp <= 0:
messages.append(create_combat_message(
"victory",
origin="neutral",
npc_name=npc_def.name
))
# Award XP/Loot logic handled in combat route mostly, but we need to signal it.
# Returning true for player_defeated is definitely WRONG here if NPC died.
# The router usually handles "victory" check after action.
# But here this is triggered during NPC turn (which happens after Player turn).
# If NPC dies on its OWN turn, we need to handle it.
# However, typically NPC dies on Player turn.
# If NPC dies from bleeding on its turn, the player wins.
# We need to signal this back to router.
# But the current return signature is (messages, player_defeated).
# We might need to handle the win logic here or update signature.
# For now, let's update HP and let the flow continue.
# Wait, if NPC is dead, it shouldn't attack!
# returning here prevents NPC from attacking if it died from status effects
messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name))
return messages, False
# Parse current intent (stored in DB as string or JSON, assuming simple string for now or we parse it)
current_intent_str = combat.get('npc_intent', 'attack')
# Handle legacy/null
if not current_intent_str:
current_intent_str = 'attack'
intent_type = current_intent_str
intent_parts = current_intent_str.split(':')
intent_type = intent_parts[0]
intent_value = intent_parts[1] if len(intent_parts) > 1 else None
actual_damage = 0
new_player_hp = player['hp']
# EXECUTE INTENT
if npc_hp > 0 and not is_stunned: # Only attack if alive and not stunned
if npc_hp > 0 and not is_stunned:
if intent_type == 'defend':
# NPC defends - heals 5% HP
heal_amount = int(combat['npc_max_hp'] * 0.05)
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
messages.append(create_combat_message("enemy_defend", origin="enemy", npc_name=npc_def.name, heal=heal_amount))
elif intent_type == 'charge':
messages.append(create_combat_message(
"enemy_defend",
origin="enemy",
npc_name=npc_def.name,
heal=heal_amount
"skill_effect", origin="enemy", message=get_game_message('enemy_charging', locale, enemy=get_locale_string(npc_def.name, locale))
))
elif intent_type == 'special':
# Strong attack (1.5x damage)
npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5)
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage)
messages.append(create_combat_message(
"enemy_special",
origin="enemy",
npc_name=npc_def.name,
damage=npc_damage,
armor_absorbed=armor_absorbed
))
if broken_armor:
for armor in broken_armor:
messages.append(create_combat_message(
"item_broken",
origin="player",
item_name=armor['name'],
emoji=armor['emoji']
))
await db.update_player(player_id, hp=new_player_hp)
else: # Default 'attack'
elif intent_type in ('charging_attack', 'special', 'attack', 'skill'):
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
skill = None
is_charging = intent_type == 'charging_attack'
# Enrage bonus if NPC is below 30% HP
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
if is_enraged:
if intent_type == 'charging_attack':
npc_damage = int(npc_damage * 2.5)
elif intent_type == 'special':
npc_damage = int(npc_damage * 1.5)
messages.append(create_combat_message(
"enemy_enraged",
origin="enemy",
npc_name=npc_def.name
))
elif intent_type == 'skill' and intent_value:
skill = skills_manager.get_skill(intent_value)
if skill:
if skill.cooldown > 0:
cd_str = f"cd_{skill.id}:0:{skill.cooldown}"
curr_combat = await db.get_active_combat(player_id)
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
new_status = curr_status + f"|{cd_str}" if curr_status else cd_str
await db.update_combat(player_id, {'npc_status_effects': new_status})
# Check if player is defending (reduces damage by value%)
effects = skill.effects
if 'heal_percent' in effects:
heal_amount = int(combat['npc_max_hp'] * effects['heal_percent'])
new_npc_hp = min(combat['npc_max_hp'], npc_hp + heal_amount)
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
messages.append(create_combat_message("skill_heal", origin="enemy", heal=heal_amount, skill_icon=skill.icon, skill_name=get_locale_string(skill.name, locale), npc_name=npc_def.name))
npc_damage = 0
if 'buff' in effects:
buff_str = f"{effects['buff']}:0:{effects['buff_duration']}"
curr_combat = await db.get_active_combat(player_id)
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
new_status = curr_status + f"|{buff_str}" if curr_status else buff_str
await db.update_combat(player_id, {'npc_status_effects': new_status})
messages.append(create_combat_message("skill_buff", origin="enemy", skill_name=get_locale_string(skill.name, locale), skill_icon=skill.icon, duration=effects['buff_duration'], npc_name=npc_def.name))
if 'damage_multiplier' not in effects and 'poison_damage' not in effects:
npc_damage = 0
if 'damage_multiplier' in effects:
npc_damage = max(1, int(npc_damage * effects['damage_multiplier']))
from api.services.helpers import calculate_dynamic_status_damage
poison_dmg = calculate_dynamic_status_damage(effects, 'poison', player)
if poison_dmg is not None:
await db.add_effect(player_id=player_id, effect_name="Poison", effect_icon="🧪", effect_type="damage", damage_per_tick=poison_dmg, ticks_remaining=effects.get('poison_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}")
burn_dmg = calculate_dynamic_status_damage(effects, 'burn', player)
if burn_dmg is not None:
await db.add_effect(player_id=player_id, effect_name="Burning", effect_icon="🔥", effect_type="damage", damage_per_tick=burn_dmg, ticks_remaining=effects.get('burn_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}")
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
if is_enraged and npc_damage > 0:
npc_damage = int(npc_damage * 1.5)
messages.append(create_combat_message("enemy_enraged", origin="enemy", npc_name=npc_def.name))
curr_combat = await db.get_active_combat(player_id)
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
if 'berserker_rage' in curr_status and npc_damage > 0:
npc_damage = int(npc_damage * 1.5)
if npc_damage > 0:
dodged = False
is_defending = False
player_effects = await db.get_player_effects(player_id)
defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None)
if defending_effect:
reduction = defending_effect.get('value', 50) / 100 # Default 50% reduction
npc_damage = int(npc_damage * (1 - reduction))
messages.append(create_combat_message(
"damage_reduced",
origin="player",
reduction=int(reduction * 100)
))
# Remove defending effect after use
is_defending = True
reduction = defending_effect.get('value', 50) / 100
npc_damage = max(1, int(npc_damage * (1 - reduction)))
messages.append(create_combat_message("damage_reduced", origin="player", reduction=int(reduction * 100)))
await db.remove_effect(player_id, 'defending')
# ── Check buff-based damage reduction (fortify) ──
buff_dmg_reduction = 0.0
if player_stats:
buff_dmg_reduction = player_stats.get('buff_damage_reduction', 0.0)
buff_dmg_reduction = player_stats.get('buff_damage_reduction', 0.0) if player_stats else 0.0
if buff_dmg_reduction > 0:
npc_damage = max(1, int(npc_damage * (1 - buff_dmg_reduction)))
messages.append(create_combat_message(
"damage_reduced",
origin="player",
reduction=int(buff_dmg_reduction * 100)
))
messages.append(create_combat_message("damage_reduced", origin="player", reduction=int(buff_dmg_reduction * 100)))
# ── Check berserker rage increased damage taken ──
buff_dmg_taken_increase = 0.0
if player_stats:
buff_dmg_taken_increase = player_stats.get('buff_damage_taken_increase', 0.0)
buff_dmg_taken_increase = player_stats.get('buff_damage_taken_increase', 0.0) if player_stats else 0.0
if buff_dmg_taken_increase > 0:
npc_damage = int(npc_damage * (1 + buff_dmg_taken_increase))
# ── Check guaranteed dodge from Evade buff ──
dodged = False
if player_stats and player_stats.get('buff_guaranteed_dodge', False):
dodged = True
messages.append(create_combat_message(
"combat_dodge",
origin="player"
))
actual_damage = 0
new_player_hp = player['hp']
# Consume the evade buff
messages.append(create_combat_message("combat_dodge", origin="player"))
await db.remove_effect(player_id, 'evade')
# ── Check Foresight buff (enemy misses) ──
if not dodged and player_stats and player_stats.get('buff_enemy_miss', False):
elif player_stats and player_stats.get('buff_enemy_miss', False):
dodged = True
messages.append(create_combat_message(
"combat_dodge",
origin="player"
))
actual_damage = 0
new_player_hp = player['hp']
# Foresight ticks down naturally via db.tick_player_effects
# Check for regular dodge (stat-based)
if not dodged and player_stats and 'dodge_chance' in player_stats:
if random.random() < player_stats['dodge_chance']:
messages.append(create_combat_message("combat_dodge", origin="player"))
elif player_stats and 'dodge_chance' in player_stats and random.random() < player_stats['dodge_chance']:
dodged = True
messages.append(create_combat_message(
"combat_dodge",
origin="player"
))
actual_damage = 0
new_player_hp = player['hp']
messages.append(create_combat_message("combat_dodge", origin="player"))
# Check for block (if shield is equipped)
blocked = False
if not dodged and player_stats and player_stats.get('has_shield', False):
if random.random() < player_stats.get('block_chance', 0):
blocked = True
messages.append(create_combat_message(
"combat_block",
origin="player"
))
npc_damage = max(1, int(npc_damage * 0.2)) # Block mitigates 80% damage
if not dodged and player_stats and player_stats.get('has_shield', False) and random.random() < player_stats.get('block_chance', 0):
messages.append(create_combat_message("combat_block", origin="player"))
npc_damage = max(1, int(npc_damage * 0.2))
if not dodged:
# Calculate armor durability loss based on PRE-reduction damage
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
# If player_stats provides a percentage reduction, apply it instead of raw absorption
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage, is_defending)
if player_stats and player_stats.get('armor_reduction', 0) > 0:
pct_reduction = player_stats['armor_reduction']
actual_damage = max(1, int(npc_damage * (1 - pct_reduction)))
@@ -891,49 +903,31 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p
new_player_hp = max(0, player['hp'] - actual_damage)
messages.append(create_combat_message(
"enemy_attack",
origin="enemy",
npc_name=npc_def.name,
damage=actual_damage,
armor_absorbed=armor_absorbed_visual
))
if skill and 'damage_multiplier' in skill.effects:
messages.append(create_combat_message("skill_attack", origin="enemy", damage=actual_damage, skill_name=get_locale_string(skill.name, locale), skill_icon=skill.icon, hits=1))
elif is_charging:
messages.append(create_combat_message("enemy_special", origin="enemy", npc_name=npc_def.name, damage=actual_damage, armor_absorbed=armor_absorbed_visual))
else:
messages.append(create_combat_message("enemy_attack", origin="enemy", npc_name=npc_def.name, damage=actual_damage, armor_absorbed=armor_absorbed_visual))
if broken_armor and not dodged:
if broken_armor:
for armor in broken_armor:
messages.append(create_combat_message(
"item_broken",
origin="player",
item_name=armor['name'],
emoji=armor['emoji']
))
messages.append(create_combat_message("item_broken", origin="player", item_name=armor['name'], emoji=armor['emoji']))
await db.update_player(player_id, hp=new_player_hp)
# GENERATE NEXT INTENT
# Check if player defeated
player_defeated = False
if player['hp'] - actual_damage <= 0 and intent_type != 'defend': # Check HP after damage
# Re-fetch to be sure or just trust calculation
if new_player_hp <= 0:
messages.append(create_combat_message(
"player_defeated",
origin="neutral",
npc_name=npc_def.name
))
if new_player_hp <= 0 and intent_type != 'defend' and intent_type != 'charge':
messages.append(create_combat_message("player_defeated", origin="neutral", npc_name=npc_def.name))
player_defeated = True
await db.update_player(player_id, hp=0, is_dead=True)
await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
await db.end_combat(player_id)
return messages, player_defeated
if not player_defeated:
if actual_damage > 0:
await db.update_player_statistics(player_id, damage_taken=actual_damage, increment=True)
# Generate NEXT intent
# We need the updated NPC HP for the logic
current_npc_hp = combat['npc_hp']
if intent_type == 'defend':
current_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + int(combat['npc_max_hp'] * 0.05))
@@ -941,13 +935,16 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p
temp_combat_state = combat.copy()
temp_combat_state['npc_hp'] = current_npc_hp
if intent_type == 'charge':
next_intent_str = 'charging_attack'
else:
next_intent = generate_npc_intent(npc_def, temp_combat_state)
next_intent_str = f"{next_intent['type']}:{next_intent['value']}" if next_intent['type'] == 'skill' else next_intent['type']
# Update combat with new intent and turn
await db.update_combat(player_id, {
'turn': 'player',
'turn_started_at': time.time(),
'npc_intent': next_intent['type']
'npc_intent': next_intent_str
})
return messages, player_defeated

53
api/give_gear.py Normal file
View File

@@ -0,0 +1,53 @@
import asyncio
import uuid
import asyncpg
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from core.config import settings
async def main():
conn = await asyncpg.connect(settings.DATABASE_URL)
# Get user
user = await conn.fetchrow("SELECT id FROM characters ORDER BY created_at DESC LIMIT 1 OFFSET 0")
if not user:
print("No user found")
return
c_id = user['id']
print(f"Adding items to character {c_id}")
# Items: Greatsword, Full Plate, Kite Shield
items = [
{"item_id": "iron_greatsword", "base_durability": 100},
{"item_id": "steel_plate", "base_durability": 200},
{"item_id": "iron_kite_shield", "base_durability": 120}
]
for item in items:
# Create unique item
unique_id = str(uuid.uuid4())
await conn.execute(
"""
INSERT INTO unique_items (id, item_id, durability, max_durability)
VALUES ($1, $2, $3, $3)
""",
unique_id, item['item_id'], item['base_durability']
)
# Add to inventory
await conn.execute(
"""
INSERT INTO inventory_items (character_id, item_id, quantity, is_equipped, unique_item_id)
VALUES ($1, $2, 1, false, $3)
""",
c_id, item['item_id'], unique_id
)
print(f"Added {item['item_id']} ({unique_id}) to inventory.")
await conn.close()
if __name__ == "__main__":
asyncio.run(main())

17
api/give_gear_final.sql Normal file
View File

@@ -0,0 +1,17 @@
DO $$
DECLARE
char_id INTEGER;
uid INTEGER;
BEGIN
SELECT id INTO char_id FROM characters ORDER BY created_at DESC LIMIT 1;
IF char_id IS NOT NULL THEN
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('iron_greatsword', 100, 100) RETURNING id INTO uid;
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'iron_greatsword', 1, false, uid);
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('steel_plate', 200, 200) RETURNING id INTO uid;
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'steel_plate', 1, false, uid);
INSERT INTO unique_items (item_id, durability, max_durability) VALUES ('iron_kite_shield', 120, 120) RETURNING id INTO uid;
INSERT INTO inventory (character_id, item_id, quantity, is_equipped, unique_item_id) VALUES (char_id, 'iron_kite_shield', 1, false, uid);
END IF;
END $$;

View File

@@ -187,8 +187,8 @@ except Exception as e:
game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager, QUESTS_DATA)
equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR)
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS)

View File

@@ -17,13 +17,14 @@ from ..services.constants import PVP_TURN_TIMEOUT
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message, get_game_message
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, get_locale_string, create_combat_message, get_game_message, get_resolved_player_effects
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
from ..core.websockets import manager
from .equipment import reduce_armor_durability
from ..services import combat_engine
from ..services.status_effects import status_effects_manager
logger = logging.getLogger(__name__)
@@ -70,6 +71,27 @@ async def get_combat_status(current_user: dict = Depends(get_current_user)):
time_elapsed = time.time() - turn_started_at
turn_time_remaining = max(0, 300 - time_elapsed)
# Parse NPC status effects
npc_effects_list = []
npc_status_str = combat.get('npc_status_effects', '') or ''
if npc_status_str:
for part in npc_status_str.split('|'):
tokens = part.split(':')
effect_name = tokens[0] if len(tokens) > 0 else ''
if not effect_name:
continue
ticks = int(tokens[2]) if len(tokens) > 2 else (int(tokens[1]) if len(tokens) > 1 else 0)
info = status_effects_manager.get_effect_info(effect_name)
npc_effects_list.append({
'name': info['name'],
'icon': info['icon'],
'ticks_remaining': ticks,
'description': info['description'],
})
# Get player active buffs/debuffs (exclude cooldowns)
player_effects = await get_resolved_player_effects(current_user['id'], in_combat=True)
return {
"in_combat": True,
"combat": {
@@ -80,8 +102,11 @@ async def get_combat_status(current_user: dict = Depends(get_current_user)):
"npc_image": f"{npc_def.image_path}" if npc_def else None,
"turn": combat['turn'],
"round": combat.get('round', 1),
"turn_time_remaining": turn_time_remaining
}
"turn_time_remaining": turn_time_remaining,
"npc_effects": npc_effects_list,
"npc_intent": combat.get('npc_intent', 'attack')
},
"player_effects": player_effects
}
@@ -154,8 +179,10 @@ async def initiate_combat(
"npc_max_hp": npc_hp,
"npc_image": f"{npc_def.image_path}",
"turn": "player",
"round": 1
}
"round": 1,
"npc_intent": "attack"
},
"player_effects": await get_resolved_player_effects(current_user['id'], in_combat=True)
},
"timestamp": datetime.utcnow().isoformat()
})
@@ -185,8 +212,10 @@ async def initiate_combat(
"npc_max_hp": npc_hp,
"npc_image": f"{npc_def.image_path}",
"turn": "player",
"round": 1
}
"round": 1,
"npc_intent": "attack"
},
"player_effects": await get_resolved_player_effects(current_user['id'], in_combat=True)
}
@@ -303,15 +332,20 @@ async def combat_action(
exclude_player_id=player['id']
)
else:
# Fetch fresh combat state to capture any player buffs applied
fresh_combat = await db.get_active_combat(player['id'])
st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '')
# NPC turn
npc_msgs, player_defeated = await combat_engine.execute_npc_turn(
player['id'],
{'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp'],
'npc_intent': combat.get('npc_intent', 'attack'),
'npc_status_effects': combat.get('npc_status_effects', '')},
'npc_status_effects': st_effects},
npc_def,
reduce_armor_durability,
redis_manager
redis_manager,
locale=locale
)
messages.extend(npc_msgs)
@@ -336,6 +370,7 @@ async def combat_action(
items_manager=ITEMS_MANAGER,
reduce_armor_func=reduce_armor_durability,
redis_manager=redis_manager,
locale=locale
)
if result.get('error'):
@@ -372,21 +407,28 @@ async def combat_action(
exclude_player_id=player['id']
)
else:
# Fetch fresh combat state to capture effects applied by the skill
fresh_combat = await db.get_active_combat(player['id'])
st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '')
# NPC turn after skill
npc_msgs, player_defeated = await combat_engine.execute_npc_turn(
player['id'],
{'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp'],
'npc_intent': combat.get('npc_intent', 'attack'),
'npc_status_effects': combat.get('npc_status_effects', '')},
'npc_status_effects': st_effects},
npc_def,
reduce_armor_durability,
redis_manager
redis_manager,
locale=locale
)
messages.extend(npc_msgs)
if player_defeated:
await db.remove_non_persistent_effects(player['id'])
combat_over = True
else:
await db.update_combat(player['id'], {'npc_hp': new_npc_hp})
# ── USE ITEM ──
elif req.action == 'use_item':
@@ -421,15 +463,20 @@ async def combat_action(
messages.extend(victory['messages'])
quest_updates = victory.get('quest_updates', [])
elif not combat_over:
# Fetch fresh combat state to capture effects applied by the item
fresh_combat = await db.get_active_combat(player['id'])
st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '')
# NPC turn after item use
npc_msgs, player_defeated = await combat_engine.execute_npc_turn(
player['id'],
{'npc_hp': result.get('target_hp', combat['npc_hp']), 'npc_max_hp': combat['npc_max_hp'],
'npc_intent': combat.get('npc_intent', 'attack'),
'npc_status_effects': combat.get('npc_status_effects', '')},
'npc_status_effects': st_effects},
npc_def,
reduce_armor_durability,
redis_manager
redis_manager,
locale=locale
)
messages.extend(npc_msgs)
@@ -441,6 +488,38 @@ async def combat_action(
if result.get('target_hp') is not None and result['target_hp'] != combat['npc_hp']:
await db.update_combat(player['id'], {'npc_hp': result['target_hp']})
# ── DEFEND ──
elif req.action == 'defend':
result = await combat_engine.execute_defend(
player_id=player['id'],
player=player,
player_stats=stats,
is_pvp=False,
locale=locale,
)
messages.extend(result['messages'])
# Fetch fresh combat state since defend could've updated stats (stamina)
fresh_combat = await db.get_active_combat(player['id'])
st_effects = fresh_combat.get('npc_status_effects', '') if fresh_combat else combat.get('npc_status_effects', '')
# NPC turn after defend
npc_msgs, player_defeated = await combat_engine.execute_npc_turn(
player['id'],
{'npc_hp': combat['npc_hp'], 'npc_max_hp': combat['npc_max_hp'],
'npc_intent': combat.get('npc_intent', 'attack'),
'npc_status_effects': st_effects},
npc_def,
reduce_armor_durability,
redis_manager,
locale=locale
)
messages.extend(npc_msgs)
if player_defeated:
await db.remove_non_persistent_effects(player['id'])
combat_over = True
# ── FLEE ──
elif req.action == 'flee':
result = await combat_engine.execute_flee_pve(
@@ -491,6 +570,7 @@ async def combat_action(
# ── Build response ──
updated_combat = None
npc_effects_list = []
if not combat_over:
raw_combat = await db.get_active_combat(current_user['id'])
if raw_combat:
@@ -499,6 +579,23 @@ async def combat_action(
turn_started_at = raw_combat.get('turn_started_at', 0)
turn_time_remaining = max(0, 300 - (time.time() - turn_started_at))
# Parse NPC status effects string into a list
npc_status_str = raw_combat.get('npc_status_effects', '') or ''
if npc_status_str:
for part in npc_status_str.split('|'):
tokens = part.split(':')
effect_name = tokens[0] if len(tokens) > 0 else ''
if not effect_name:
continue
ticks = int(tokens[2]) if len(tokens) > 2 else (int(tokens[1]) if len(tokens) > 1 else 0)
info = status_effects_manager.get_effect_info(effect_name)
npc_effects_list.append({
'name': info['name'],
'icon': info['icon'],
'ticks_remaining': ticks,
'description': info['description'],
})
updated_combat = {
"npc_id": raw_combat['npc_id'],
"npc_name": npc_def.name,
@@ -507,13 +604,75 @@ async def combat_action(
"npc_image": f"{npc_def.image_path}",
"turn": raw_combat['turn'],
"round": raw_combat.get('round', 1),
"turn_time_remaining": turn_time_remaining
"turn_time_remaining": turn_time_remaining,
"npc_effects": npc_effects_list,
"npc_intent": raw_combat.get('npc_intent', 'attack')
}
# Get player active buffs/debuffs (exclude cooldowns)
player_effects = []
if not combat_over:
from ..services.skills import skills_manager
all_effects = await db.get_player_effects(current_user['id'])
for eff in all_effects:
if eff.get('effect_type') == 'cooldown':
continue
resolved = status_effects_manager.resolve_player_effect(
eff.get('effect_name', ''),
eff.get('effect_icon', ''),
eff.get('source', ''),
skills_manager
)
player_effects.append({
'name': resolved['name'],
'icon': resolved['icon'],
'ticks_remaining': eff.get('ticks_remaining', 0),
'type': eff.get('effect_type', 'buff'),
'description': resolved['description'],
})
updated_player = await db.get_player_by_id(current_user['id'])
if not updated_player:
updated_player = current_user
equipment_slots = await db.get_all_equipment(current_user['id'])
equipment = {}
for slot, item_data in equipment_slots.items():
if item_data and item_data['item_id']:
inv_item = await db.get_inventory_item_by_id(item_data['item_id'])
if inv_item:
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
if item_def:
# Get unique item data if this is a unique item
durability = None
max_durability = None
tier = None
unique_stats = None
if inv_item.get('unique_item_id'):
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
if unique_item:
durability = unique_item.get('durability')
max_durability = unique_item.get('max_durability')
tier = unique_item.get('tier')
unique_stats = unique_item.get('unique_stats')
equipment[slot] = {
"inventory_id": item_data['item_id'],
"item_id": item_def.id,
"name": item_def.name,
"description": item_def.description,
"emoji": item_def.emoji,
"image_path": item_def.image_path,
"durability": durability if durability is not None else None,
"max_durability": max_durability if max_durability is not None else None,
"tier": tier if tier is not None else None,
"unique_stats": unique_stats,
"stats": item_def.stats,
"encumbrance": item_def.encumbrance,
"weapon_effects": item_def.weapon_effects if hasattr(item_def, 'weapon_effects') else {}
}
if slot not in equipment:
equipment[slot] = None
return {
"success": True,
"messages": messages,
@@ -526,6 +685,8 @@ async def combat_action(
"xp": updated_player['xp'],
"level": updated_player['level']
},
"player_effects": player_effects,
"equipment": equipment,
"quest_updates": quest_updates
}
@@ -887,6 +1048,7 @@ async def pvp_combat_action(
items_manager=ITEMS_MANAGER,
reduce_armor_func=reduce_armor_durability,
redis_manager=redis_manager,
locale=locale
)
if result.get('error'):
@@ -978,6 +1140,25 @@ async def pvp_combat_action(
'last_action': f"{last_action_text}|{time.time()}"
})
# ── DEFEND ──
elif req.action == 'defend':
result = await combat_engine.execute_defend(
player_id=current_player['id'],
player=current_player,
player_stats=current_player_stats,
is_pvp=True,
locale=locale,
)
messages.extend(result['messages'])
last_action_text = f"{current_player['name']} took a defensive stance!"
# Switch turns
await db.update_pvp_combat(pvp_combat['id'], {
'turn': 'defender' if is_attacker else 'attacker',
'turn_started_at': time.time(),
'last_action': f"{last_action_text}|{time.time()}"
})
# ── FLEE ──
elif req.action == 'flee':
result = await combat_engine.execute_flee_pvp(

View File

@@ -25,13 +25,15 @@ logger = logging.getLogger(__name__)
LOCATIONS = None
ITEMS_MANAGER = None
WORLD = None
redis_manager = None
def init_router_dependencies(locations, items_manager, world):
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
"""Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
redis_manager = redis_mgr
router = APIRouter(tags=["crafting"])
@@ -509,9 +511,8 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
adjusted_quantity = int(round(base_quantity * durability_ratio))
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
mat_name = mat_def.name if mat_def else material['item_id']
loss_key = (material['item_id'], mat_name)
loss_key = material['item_id']
# If durability is too low (< 10%), yield nothing for this material
if durability_ratio < 0.1 or adjusted_quantity <= 0:
@@ -535,7 +536,7 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
# But we need to check capacity.
# Let's accumulate pending yield.
yield_key = (material['item_id'], mat_name, mat_def.emoji if mat_def else '📦', mat_def)
yield_key = material['item_id']
if yield_key not in materials_yielded_dict:
materials_yielded_dict[yield_key] = 0
materials_yielded_dict[yield_key] += adjusted_quantity
@@ -546,18 +547,23 @@ async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends
materials_dropped = []
# Convert lost dict to list
for (item_id, name), qty in materials_lost_dict.items():
for item_id, qty in materials_lost_dict.items():
mat_def = ITEMS_MANAGER.items.get(item_id)
materials_lost.append({
'item_id': item_id,
'name': name,
'quantity': qty,
'reason': 'lost_or_low_durability'
'name': mat_def.name if mat_def else item_id,
'emoji': mat_def.emoji if mat_def else '📦',
'quantity': qty
})
# Process yield
for (item_id, name, emoji, mat_def), qty in materials_yielded_dict.items():
mat_weight = getattr(mat_def, 'weight', 0) * qty
mat_volume = getattr(mat_def, 'volume', 0) * qty
for item_id, qty in materials_yielded_dict.items():
mat_def = ITEMS_MANAGER.items.get(item_id)
mat_name = mat_def.name if mat_def else item_id
emoji = mat_def.emoji if mat_def else '📦'
mat_weight = getattr(mat_def, 'weight', 0) * qty if mat_def else 0
mat_volume = getattr(mat_def, 'volume', 0) * qty if mat_def else 0
# Simple check against capacity (assuming current_weight was just updated from DB)
# Note: we might fill up mid-loop. ideally we add one by one or check total.

View File

@@ -50,6 +50,14 @@ async def equip_item(
player_id = current_user['id']
locale = request.headers.get('Accept-Language', 'en')
# Check if in combat
in_combat = await db.get_active_combat(player_id)
if in_combat:
raise HTTPException(
status_code=400,
detail=get_game_message('cannot_equip_combat', locale)
)
# Get the inventory item
inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id)
if not inv_item or inv_item['character_id'] != player_id:
@@ -156,6 +164,14 @@ async def unequip_item(
player_id = current_user['id']
locale = request.headers.get('Accept-Language', 'en')
# Check if in combat
in_combat = await db.get_active_combat(player_id)
if in_combat:
raise HTTPException(
status_code=400,
detail=get_game_message('cannot_equip_combat', locale)
)
# Check if slot is valid
valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack']
if unequip_req.slot not in valid_slots:
@@ -412,7 +428,7 @@ async def repair_item(
async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple:
async def reduce_armor_durability(player_id: int, damage_taken: int, is_defending: bool = False) -> tuple:
"""
Reduce durability of equipped armor pieces when taking damage.
Formula: durability_loss = max(1, (damage_taken / armor_value) * base_reduction_rate)
@@ -452,7 +468,7 @@ async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple:
# Calculate durability loss for each armor piece
# Balanced formula: armor should last many combats (10-20+ hits for low tier)
base_reduction_rate = 0.1 # Reduced from 0.5 to make armor more durable
base_reduction_rate = 0.2 if is_defending else 0.1 # Reduced from 0.5 to make armor more durable
broken_armor = []
for armor in equipped_armor:

View File

@@ -228,7 +228,8 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
raise HTTPException(status_code=404, detail="Player not found")
# Get player status effects
status_effects = await db.get_player_effects(player_id)
from ..services.helpers import get_resolved_player_effects
status_effects = await get_resolved_player_effects(player_id)
player['status_effects'] = status_effects
# Get location
@@ -375,13 +376,21 @@ async def get_game_state(current_user: dict = Depends(get_current_user)):
"tags": getattr(location, 'tags', [])
}
from ..services.stats import calculate_derived_stats
derived_stats = await calculate_derived_stats(player_id, redis_manager)
# Add weight/volume to player data
player_with_capacity = dict(player)
player_with_capacity['current_weight'] = round(total_weight, 2)
player_with_capacity['max_weight'] = round(max_weight, 2)
player_with_capacity['current_volume'] = round(total_volume, 2)
player_with_capacity['max_weight'] = round(derived_stats.get('carry_weight', max_weight), 2)
player_with_capacity['max_volume'] = round(max_volume, 2)
player_with_capacity['max_hp'] = derived_stats.get('max_hp', player['max_hp'])
player_with_capacity['max_stamina'] = derived_stats.get('max_stamina', player['max_stamina'])
player_with_capacity['derived_stats'] = derived_stats
# Calculate movement cooldown
import time
current_time = time.time()
@@ -412,20 +421,29 @@ async def get_player_profile(current_user: dict = Depends(get_current_user)):
raise HTTPException(status_code=404, detail="Player not found")
# Get player status effects
status_effects = await db.get_player_effects(player_id)
from ..services.helpers import get_resolved_player_effects
status_effects = await get_resolved_player_effects(player_id)
player['status_effects'] = status_effects
# Get capacity metrics (weight/volume) using the helper function
# We don't need the inventory array itself, just the capacity calculations
_, total_weight, total_volume, max_weight, max_volume = await _get_enriched_inventory(player_id)
from ..services.stats import calculate_derived_stats
derived_stats = await calculate_derived_stats(player_id, redis_manager)
# Add weight/volume to player data
player_with_capacity = dict(player)
player_with_capacity['current_weight'] = round(total_weight, 2)
player_with_capacity['max_weight'] = round(max_weight, 2)
player_with_capacity['current_volume'] = round(total_volume, 2)
player_with_capacity['max_weight'] = round(derived_stats.get('carry_weight', max_weight), 2)
player_with_capacity['max_volume'] = round(max_volume, 2)
player_with_capacity['max_hp'] = derived_stats.get('max_hp', player['max_hp'])
player_with_capacity['max_stamina'] = derived_stats.get('max_stamina', player['max_stamina'])
player_with_capacity['derived_stats'] = derived_stats
# Calculate movement cooldown
import time
current_time = time.time()
@@ -962,6 +980,7 @@ async def move(
await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True)
encounter_triggered = True
from ..services.helpers import get_resolved_player_effects
combat_data = {
"npc_id": enemy_id,
"npc_name": npc_def.name,
@@ -972,6 +991,7 @@ async def move(
"round": 1,
"npc_intent": initial_intent['type']
}
player_effects = await get_resolved_player_effects(current_user['id'], in_combat=True)
response = {
"success": True,
@@ -986,7 +1006,8 @@ async def move(
"triggered": True,
"enemy_id": enemy_id,
"message": get_game_message('enemy_ambush', locale),
"combat": combat_data
"combat": combat_data,
"player_effects": player_effects
}
# Broadcast movement to WebSocket clients
@@ -1585,6 +1606,10 @@ async def get_character_sheet(current_user: dict = Depends(get_current_user)):
# Get all perks with availability
all_perks = perks_manager.get_available_perks(player, owned_perk_ids)
# Get active status effects
from ..services.helpers import get_resolved_player_effects
status_effects = await get_resolved_player_effects(character_id)
# Calculate perk points
total_perk_points = get_total_perk_points(player['level'])
used_perk_points = len(owned_perk_ids)
@@ -1607,6 +1632,7 @@ async def get_character_sheet(current_user: dict = Depends(get_current_user)):
"used_points": used_perk_points,
"all_perks": all_perks,
},
"status_effects": status_effects,
"character": {
"name": player['name'],
"level": player['level'],

View File

@@ -25,13 +25,15 @@ logger = logging.getLogger(__name__)
LOCATIONS = None
ITEMS_MANAGER = None
WORLD = None
redis_manager = None
def init_router_dependencies(locations, items_manager, world):
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
"""Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
redis_manager = redis_mgr
router = APIRouter(tags=["loot"])

View File

@@ -208,8 +208,18 @@ async def execute_attack(
broken_armor = []
if is_pvp:
is_defending = False
target_effects = await db.get_player_effects(target['id'])
defending_effect = next((e for e in target_effects if e['effect_name'] == 'defending'), None)
if defending_effect:
is_defending = True
reduction = defending_effect.get('value', 50) / 100
damage = max(1, int(damage * (1 - reduction)))
messages.append(create_combat_message("damage_reduced", origin="enemy", reduction=int(reduction * 100)))
await db.remove_effect(target['id'], 'defending')
# PvP: use equipment-based armor reduction + durability
armor_absorbed, broken_armor = await reduce_armor_func(target['id'], damage)
armor_absorbed, broken_armor = await reduce_armor_func(target['id'], damage, is_defending)
actual_damage = max(1, damage - armor_absorbed)
else:
# PvE: use NPC's flat defense value
@@ -279,6 +289,54 @@ async def execute_attack(
}
async def execute_defend(
player_id: int,
player: dict,
player_stats: dict,
is_pvp: bool,
locale: str = 'en'
) -> Dict[str, Any]:
"""
Execute a defend action.
Reduces incoming damage by 50% for the next turn, but increases durability loss.
Returns: {
messages: list
}
"""
from .. import database as db
from .helpers import create_combat_message, get_game_message
messages = []
# 5% Stamina restore
stamina_restore = max(5, int(player_stats.get('max_stamina', 100) * 0.05))
new_stamina = min(player_stats.get('max_stamina', 100), player.get('stamina', 100) + stamina_restore)
await db.update_player(player_id, stamina=new_stamina)
# Add defending effect
await db.add_effect(
player_id=player_id,
effect_name="defending",
effect_icon="🛡️",
effect_type="buff",
ticks_remaining=1,
persist_after_combat=False,
source="combat_defend",
value=50 # 50% reduction
)
messages.append(create_combat_message(
"player_defend",
origin="player"
))
return {
'messages': messages,
'stamina_restored': stamina_restore,
}
# ============================================================================
# SKILL ACTION
# ============================================================================
@@ -294,6 +352,7 @@ async def execute_skill(
items_manager: ItemsManager,
reduce_armor_func,
redis_manager=None,
locale: str = 'en'
) -> Dict[str, Any]:
"""
Execute a skill action. Validates requirements, deducts stamina, applies effects.
@@ -402,6 +461,17 @@ async def execute_skill(
if effects.get('guaranteed_crit'):
damage = int(damage * player_stats.get('crit_damage', 1.5))
is_defending = False
if is_pvp:
target_effects = await db.get_player_effects(target['id'])
defending_effect = next((e for e in target_effects if e['effect_name'] == 'defending'), None)
if defending_effect:
is_defending = True
reduction = defending_effect.get('value', 50) / 100
damage = max(1, int(damage * (1 - reduction)))
messages.append(create_combat_message("damage_reduced", origin="enemy", reduction=int(reduction * 100)))
await db.remove_effect(target['id'], 'defending')
# Multi-hit
num_hits = effects.get('hits', 1)
total_damage = 0
@@ -412,7 +482,7 @@ async def execute_skill(
if is_pvp:
# PvP: armor from equipment
absorbed, broken_armor = await reduce_armor_func(target['id'], hit_dmg)
absorbed, broken_armor = await reduce_armor_func(target['id'], hit_dmg, is_defending)
total_armor_absorbed += absorbed
for broken in broken_armor:
messages.append(create_combat_message(
@@ -450,8 +520,11 @@ async def execute_skill(
"skill_heal", origin="player", heal=heal_amount, skill_icon="🩸"
))
from .helpers import calculate_dynamic_status_damage
# Poison DoT
if 'poison_damage' in effects:
poison_dmg = calculate_dynamic_status_damage(effects, 'poison', target)
if poison_dmg is not None:
poison_dur = effects.get('poison_duration', 3)
if is_pvp:
# PvP: add as player effect
await db.add_effect(
@@ -459,14 +532,14 @@ async def execute_skill(
effect_name="Poison",
effect_icon="🧪",
effect_type="damage",
damage_per_tick=effects['poison_damage'],
ticks_remaining=effects['poison_duration'],
damage_per_tick=poison_dmg,
ticks_remaining=poison_dur,
persist_after_combat=True,
source=f"skill_poison:{skill.id}"
)
else:
# PvE: add to npc_status_effects string
poison_str = f"poison:{effects['poison_damage']}:{effects['poison_duration']}"
poison_str = f"poison:{poison_dmg}:{poison_dur}"
existing = combat_state.get('npc_status_effects', '') or ''
if existing:
existing += '|' + poison_str
@@ -476,7 +549,38 @@ async def execute_skill(
messages.append(create_combat_message(
"skill_effect", origin="player",
message=f"🧪 Poisoned! ({effects['poison_damage']} dmg/turn)"
message=f"🧪 Poisoned! ({poison_dmg} dmg/turn)"
))
# Burn DoT
burn_dmg = calculate_dynamic_status_damage(effects, 'burn', target)
if burn_dmg is not None:
burn_dur = effects.get('burn_duration', 3)
if is_pvp:
# PvP: add as player effect
await db.add_effect(
player_id=target['id'],
effect_name="Burning",
effect_icon="🔥",
effect_type="damage",
damage_per_tick=burn_dmg,
ticks_remaining=burn_dur,
persist_after_combat=True,
source=f"skill_burn:{skill.id}"
)
else:
# PvE: add to npc_status_effects string
burn_str = f"burning:{burn_dmg}:{burn_dur}"
existing = combat_state.get('npc_status_effects', '') or ''
if existing:
existing += '|' + burn_str
else:
existing = burn_str
await db.update_combat(player_id, {'npc_status_effects': existing})
messages.append(create_combat_message(
"skill_effect", origin="player",
message=f"🔥 Burning! ({burn_dmg} dmg/turn)"
))
# Stun chance
@@ -506,7 +610,7 @@ async def execute_skill(
await db.update_combat(player_id, {'npc_status_effects': existing})
messages.append(create_combat_message(
"skill_effect", origin="player", message="💫 Stunned!"
"skill_effect", origin="player", message=get_game_message('stunned_status', locale)
))
# Weapon durability
@@ -721,7 +825,13 @@ async def execute_use_item(
# 6. Status effect on target (burn from molotov etc.) — PvE only
status_effect = combat_effects.get('status') if not is_pvp else None
if status_effect and not target_defeated:
npc_status = f"{status_effect['name']}:{status_effect.get('damage_per_tick', 0)}:{status_effect.get('ticks', 1)}"
dmg = status_effect.get('damage_per_tick', 0)
if 'damage_percent' in status_effect:
max_hp = target.get('npc_max_hp', target.get('max_hp', 100))
base_dmg = max_hp * status_effect['damage_percent']
dmg = random.randint(max(1, int(base_dmg * 0.8)), max(1, int(base_dmg * 1.2)))
npc_status = f"{status_effect['name']}:{dmg}:{status_effect.get('ticks', 1)}"
await db.update_combat(player_id, {'npc_status_effects': npc_status})
messages.append(create_combat_message(
"effect_applied", origin="player",
@@ -1123,6 +1233,7 @@ async def execute_npc_turn(
npc_def,
reduce_armor_func,
redis_manager=None,
locale: str = 'en'
) -> Tuple[List[dict], bool]:
"""
Execute the NPC's turn with buff-aware damage reduction.
@@ -1145,7 +1256,7 @@ async def execute_npc_turn(
from ..game_logic import npc_attack
messages, player_defeated = await npc_attack(
player_id, combat, npc_def, reduce_armor_func, player_stats=stats
player_id, combat, npc_def, reduce_armor_func, player_stats=stats, locale=locale
)
return messages, player_defeated

View File

@@ -3,11 +3,27 @@ Helper utilities for game calculations and common operations.
Contains distance calculations, stamina costs, capacity calculations, etc.
"""
import math
from typing import Tuple, List, Dict, Any, Union
import random
from typing import Tuple, List, Dict, Any, Union, Optional
from .. import database as db
from ..items import ItemsManager
def calculate_dynamic_status_damage(effects: dict, prefix: str, target: dict) -> Optional[int]:
"""Helper to calculate status damage based on percentage over max HP."""
if f'{prefix}_percent' in effects:
target_max_hp = target.get('max_hp') or target.get('npc_max_hp', 100)
pct = effects[f'{prefix}_percent']
base_dmg = target_max_hp * pct
# +/- 20% deviation
min_dmg = max(1, int(base_dmg * 0.8))
max_dmg = max(1, int(base_dmg * 1.2))
return random.randint(min_dmg, max_dmg)
elif f'{prefix}_damage' in effects:
return effects[f'{prefix}_damage']
return None
def get_locale_string(value: Union[str, Dict[str, str]], lang: str = 'en') -> str:
"""Helper to safely get string from i18n object or string."""
if isinstance(value, dict):
@@ -54,6 +70,8 @@ GAME_MESSAGES = {
'pvp_combat_started_broadcast': {'en': "⚔️ PvP combat started between {attacker} and {defender}!", 'es': "⚔️ ¡Combate PvP iniciado entre {attacker} y {defender}!"},
'flee_success_text': {'en': "{name} fled from combat!", 'es': "¡{name} huyó del combate!"},
'flee_fail_text': {'en': "{name} tried to flee but failed!", 'es': "¡{name} intentó huir pero falló!"},
'stunned_status': {'en': "💫 Stunned!", 'es': "💫 ¡Aturdido!"},
'npc_stunned_cannot_act': {'en': "💫 {npc_name} is stunned and cannot act!", 'es': "💫 ¡{npc_name} está aturdido y no puede actuar!"},
# Loot
'corpse_name_npc': {'en': "{name} Corpse", 'es': "Cadáver de {name}"},
@@ -101,12 +119,16 @@ GAME_MESSAGES = {
'item_used': {'en': "Used {name}", 'es': "Usado {name}"},
'no_item': {'en': "You don't have this item", 'es': "No tienes este objeto"},
'cannot_use': {'en': "This item cannot be used", 'es': "Este objeto no se puede usar"},
'cannot_equip_combat': {'en': "Cannot change equipment during combat", 'es': "No puedes cambiar de equipamiento durante el combate"},
'cured': {'en': "Cured", 'es': "Curado"},
# Status Effects
'statusDamage': {'en': "You took {damage} damage from status effects", 'es': "Has recibido {damage} de daño por efectos de estado"},
'statusHeal': {'en': "You recovered {heal} HP from status effects", 'es': "Has recuperado {heal} vida por efectos de estado"},
'diedFromStatus': {'en': "You died from status effects", 'es': "Has muerto por efectos de estado"},
# Combat Warnings
'enemy_charging': {'en': "⚠️ {enemy} is gathering strength for a massive attack!", 'es': "⚠️ ¡{enemy} está reuniendo fuerzas para un ataque masivo!"},
}
def get_game_message(key: str, lang: str = 'en', **kwargs) -> str:
@@ -140,6 +162,36 @@ def translate_travel_message(direction: str, location_name: str, lang: str = 'en
import json
from typing import List, Dict, Any, Tuple
from .. import database as db
async def get_resolved_player_effects(player_id: int, in_combat: bool = False) -> List[Dict]:
"""Helper to fetch and format active player effects for combat payloads."""
from ..services.skills import skills_manager
from ..services.status_effects import status_effects_manager
player_effects = []
all_effects = await db.get_player_effects(player_id)
for eff in all_effects:
if eff.get('effect_type') == 'cooldown':
continue
resolved = status_effects_manager.resolve_player_effect(
eff.get('effect_name', ''),
eff.get('effect_icon', ''),
eff.get('source', ''),
skills_manager,
in_combat=in_combat
)
player_effects.append({
'name': resolved['name'],
'effect_name': eff.get('effect_name', ''), # Needed for frontend state tracking
'icon': resolved['icon'],
'ticks_remaining': eff.get('ticks_remaining', 0),
'damage_per_tick': eff.get('damage_per_tick', 0), # Needed for logic
'type': eff.get('effect_type', 'buff'),
'description': resolved['description'],
})
return player_effects
def create_combat_message(message_type: str, origin: str = "neutral", **data) -> dict:
"""Create a structured combat message object.
@@ -274,7 +326,7 @@ async def calculate_player_capacity(inventory: List[Dict[str, Any]], items_manag
return current_weight, max_weight, current_volume, max_volume
async def reduce_armor_durability(player_id: int, damage_taken: int, items_manager: ItemsManager) -> Tuple[int, List[Dict[str, Any]]]:
async def reduce_armor_durability(player_id: int, damage_taken: int, items_manager: ItemsManager, is_defending: bool = False) -> Tuple[int, List[Dict[str, Any]]]:
"""
Reduce durability of equipped armor pieces when taking damage.
Returns: (armor_damage_absorbed, broken_armor_pieces)
@@ -311,7 +363,7 @@ async def reduce_armor_durability(player_id: int, damage_taken: int, items_manag
armor_absorbed = min(damage_taken // 2, total_armor)
# Calculate durability loss for each armor piece
base_reduction_rate = 0.1
base_reduction_rate = 0.2 if is_defending else 0.1
broken_armor = []
for armor in equipped_armor:

View File

@@ -60,6 +60,10 @@ class SkillsManager:
"""
available = []
for skill_id, skill in self.skills.items():
# Skip NPC-only skills (assumed to be those with 0 stat threshold and level 1 requirement)
if (skill.stat_threshold <= 0 and skill.level_requirement <= 1) or getattr(skill, 'npc_only', False):
continue
stat_value = character.get(skill.stat_requirement, 0)
level = character.get('level', 1)

View File

@@ -36,7 +36,30 @@ async def calculate_derived_stats(character_id: int, redis_mgr=None) -> Dict[str
if not char:
return _empty_stats()
equipment = await db.get_all_equipment(character_id)
raw_equipment = await db.get_all_equipment(character_id)
enriched_equipment = {}
for slot, item_data in raw_equipment.items():
if not item_data or not item_data.get('item_id'):
continue
inv_item = await db.get_inventory_item_by_id(item_data['item_id'])
if not inv_item:
continue
enriched_item = {
'item_id': inv_item['item_id'], # String ID
'inventory_id': item_data['item_id']
}
unique_item_id = inv_item.get('unique_item_id')
if unique_item_id:
unique_item = await db.get_unique_item(unique_item_id)
if unique_item and unique_item.get('unique_stats'):
enriched_item['unique_stats'] = unique_item['unique_stats']
enriched_equipment[slot] = enriched_item
effects = await db.get_player_effects(character_id)
# 3. Fetch owned perks
@@ -44,7 +67,7 @@ async def calculate_derived_stats(character_id: int, redis_mgr=None) -> Dict[str
owned_perk_ids = [row['perk_id'] for row in owned_perks]
# 4. Compute derived stats
stats = _compute_stats(char, equipment, effects, owned_perk_ids)
stats = _compute_stats(char, enriched_equipment, effects, owned_perk_ids)
# 5. Cache in Redis (5 min TTL)
if redis_mgr and redis_mgr.redis_client:
@@ -95,21 +118,29 @@ def _compute_stats(char: Dict[str, Any], equipment: Dict[str, Any], effects: Lis
if not item_data or not item_data.get('item_id'):
continue
# Get inventory item to find the item definition
inv_item_sync = item_data # equipment dict already has item_id reference
item_def = ITEMS_MANAGER.get_item(inv_item_sync.get('item_id', ''))
item_id_str = item_data.get('item_id', '')
item_def = ITEMS_MANAGER.get_item(item_id_str)
# Try to get item_id from the inventory item if the direct lookup failed
if not item_def:
continue
# Merge base stats and unique stats
merged_stats = {}
if item_def.stats:
total_armor += item_def.stats.get('armor', 0)
weapon_crit += item_def.stats.get('crit_chance', 0)
merged_stats.update(item_def.stats)
if item_data.get('unique_stats'):
merged_stats.update(item_data['unique_stats'])
if merged_stats:
total_armor += merged_stats.get('armor', 0)
weapon_crit += merged_stats.get('crit_chance', 0)
max_hp += merged_stats.get('max_hp', 0)
max_stamina += merged_stats.get('max_stamina', 0)
carry_weight += merged_stats.get('weight_capacity', 0)
if slot == 'weapon':
weapon_damage_min = item_def.stats.get('damage_min', 0)
weapon_damage_max = item_def.stats.get('damage_max', 0)
weapon_damage_min = merged_stats.get('damage_min', 0)
weapon_damage_max = merged_stats.get('damage_max', 0)
if slot == 'offhand':
has_shield = True
@@ -219,6 +250,28 @@ async def invalidate_stats_cache(character_id: int, redis_mgr=None):
except Exception:
pass
# Sync derived max_hp and max_stamina to the database characters table
try:
derived = await calculate_derived_stats(character_id, redis_mgr)
char = await db.get_player_by_id(character_id)
if char:
new_max_hp = derived.get('max_hp', char['max_hp'])
new_max_stamina = derived.get('max_stamina', char['max_stamina'])
if new_max_hp != char['max_hp'] or new_max_stamina != char['max_stamina']:
new_hp = min(char['hp'], new_max_hp)
new_stamina = min(char['stamina'], new_max_stamina)
await db.update_player(
character_id,
max_hp=new_max_hp,
max_stamina=new_max_stamina,
hp=new_hp,
stamina=new_stamina
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Failed to sync derived stats to DB for {character_id}: {e}")
def get_flee_chance(flee_chance_base: float, enemy_level: int) -> float:
"""Calculate actual flee chance against a specific enemy."""

View File

@@ -0,0 +1,101 @@
"""
Status Effects Manager.
Loads status effect definitions from gamedata/status_effects.json.
"""
import json
import os
from typing import Dict, Any, Optional
class StatusEffect:
"""Represents a status effect definition."""
def __init__(self, effect_id: str, data: Dict[str, Any]):
self.id = effect_id
self.icon = data.get('icon', '')
self.name = data.get('name', effect_id.capitalize())
self.description = data.get('description', effect_id.capitalize())
self.type = data.get('type', 'debuff')
class StatusEffectsManager:
"""Manages status effect definitions loaded from JSON."""
def __init__(self, gamedata_path: str = "./gamedata"):
self.effects: Dict[str, StatusEffect] = {}
filepath = os.path.join(gamedata_path, 'status_effects.json')
self._load(filepath)
def _load(self, filepath: str):
"""Load status effects from a JSON file."""
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
for effect_id, effect_data in data.get('effects', {}).items():
self.effects[effect_id] = StatusEffect(effect_id, effect_data)
print(f"✨ Loaded {len(self.effects)} status effects")
except FileNotFoundError:
print("⚠️ status_effects.json not found")
except Exception as e:
print(f"⚠️ Error loading status_effects.json: {e}")
def get_effect(self, effect_id: str) -> Optional[StatusEffect]:
"""Get a status effect by its ID."""
return self.effects.get(effect_id)
def get_effect_info(self, effect_id: str) -> Dict[str, Any]:
"""Get effect info dict for API responses. Returns a fallback if not found."""
effect = self.effects.get(effect_id)
if effect:
return {
'name': effect.name,
'icon': effect.icon,
'description': effect.description,
'type': effect.type,
}
# Fallback for unknown effects
return {
'name': {'en': effect_id.capitalize(), 'es': effect_id.capitalize()},
'icon': '',
'description': {'en': effect_id.capitalize(), 'es': effect_id.capitalize()},
'type': 'debuff',
}
def resolve_player_effect(self, effect_name: str, effect_icon: str, source: str, skills_manager=None, in_combat: bool = True) -> Dict[str, Any]:
"""
Resolve translated name and description for a player effect.
Tries skill source first, then status_effects.json, then fallback.
"""
translated_name = effect_name
translated_desc = ''
# 1. Try to get from skill source (e.g., "skill:fortify")
if source.startswith('skill:') and skills_manager:
skill_id = source.split(':', 1)[1]
skill_def = skills_manager.get_skill(skill_id)
if skill_def:
translated_name = skill_def.name
translated_desc = skill_def.description
# 2. Try to get from status_effects.json by lowercased effect name
if not translated_desc:
effect_key = effect_name.lower()
effect = self.effects.get(effect_key)
if effect:
translated_name = effect.name
translated_desc = effect.description
# 3. Fallback: wrap the raw name as a translatable dict
if not translated_desc:
translated_desc = {'en': effect_name, 'es': effect_name}
if isinstance(translated_name, str):
translated_name = {'en': translated_name, 'es': translated_name}
return {
'name': translated_name,
'icon': effect_icon or '',
'description': translated_desc,
}
# Module-level singleton
status_effects_manager = StatusEffectsManager()

View File

@@ -897,7 +897,7 @@
"repair_percentage": 25,
"stats": {
"armor": 3,
"hp_bonus": 10
"max_hp": 10
},
"emoji": "🦺",
"image_path": "images/items/leather_vest.webp",
@@ -988,7 +988,7 @@
"repair_percentage": 25,
"stats": {
"armor": 2,
"stamina_bonus": 5
"max_stamina": 5
},
"emoji": "🥾",
"image_path": "images/items/sturdy_boots.webp",
@@ -1036,7 +1036,7 @@
"repair_percentage": 25,
"stats": {
"armor": 2,
"hp_bonus": 5
"max_hp": 5
},
"emoji": "👖",
"image_path": "images/items/padded_pants.webp",
@@ -1261,7 +1261,7 @@
"status": {
"name": "burning",
"icon": "🔥",
"damage_per_tick": 3,
"damage_percent": 0.08,
"ticks": 3,
"persist_after_combat": true
}
@@ -1279,8 +1279,8 @@
"emoji": "💨",
"image_path": "images/items/smoke_bomb.webp",
"description": {
"en": "Creates a smoke screen. Greatly increases flee chance for 1 turn.",
"es": "Crea una cortina de humo. Aumenta la probabilidad de huir por 1 turno."
"en": "Creates a smoke screen. Greatly increases flee chance for 1 {{interval}}.",
"es": "Crea una cortina de humo. Aumenta la probabilidad de huir por 1 {{interval}}."
},
"stackable": true,
"combat_usable": true,
@@ -1328,8 +1328,8 @@
"emoji": "⚡",
"image_path": "images/items/adrenaline.webp",
"description": {
"en": "Increases damage output for 2 turns. Only usable in combat.",
"es": "Aumenta el daño durante 2 turnos. Solo usable en combate."
"en": "Increases damage output for 2 {{intervals_plural}}. Only usable in combat.",
"es": "Aumenta el daño durante 2 {{intervals_plural}}. Solo usable en combate."
},
"stackable": true,
"consumable": true,

View File

@@ -47,6 +47,10 @@
],
"flee_chance": 0.3,
"status_inflict_chance": 0.15,
"skills": [
"rabid_bite",
"howl"
],
"image_path": "images/npcs/feral_dog.webp",
"death_message": {
"en": "The feral dog whimpers and collapses. Perhaps it was just hungry...",
@@ -112,6 +116,10 @@
],
"flee_chance": 0.2,
"status_inflict_chance": 0.1,
"skills": [
"bandage_self",
"quick_slash"
],
"image_path": "images/npcs/raider_scout.webp",
"death_message": {
"en": "The raider scout falls with a final gasp. Their supplies are yours.",
@@ -159,6 +167,9 @@
],
"flee_chance": 0.5,
"status_inflict_chance": 0.25,
"skills": [
"rabid_bite"
],
"image_path": "images/npcs/mutant_rat.webp",
"death_message": {
"en": "The mutant rat squeals its last and goes still.",
@@ -212,6 +223,10 @@
],
"flee_chance": 0.1,
"status_inflict_chance": 0.3,
"skills": [
"rabid_bite",
"power_strike"
],
"image_path": "images/npcs/infected_human.webp",
"death_message": {
"en": "The infected human finally finds peace in death.",
@@ -289,6 +304,10 @@
],
"flee_chance": 0.25,
"status_inflict_chance": 0.05,
"skills": [
"bandage_self",
"power_strike"
],
"image_path": "images/npcs/scavenger.webp",
"death_message": {
"en": "The scavenger's struggle ends. Survival has no mercy.",
@@ -305,6 +324,7 @@
"en": "A huge terrifying monster.",
"es": "Un monstruo enorme y aterrador."
},
"image_path": "images/npcs/test_boss.webp",
"emoji": "👹",
"hp_min": 1000,
"hp_max": 2000,
@@ -315,6 +335,11 @@
"loot_table": [],
"flee_chance": 0.0,
"status_inflict_chance": 0.5,
"skills": [
"howl",
"power_strike",
"crushing_blow"
],
"death_message": {
"en": "The boss is defeated.",
"es": "El jefe ha sido derrotado."

View File

@@ -91,8 +91,8 @@
"es": "Resistente"
},
"description": {
"en": "Status effects last 1 fewer turn (min 1)",
"es": "Los efectos de estado duran 1 turno menos (mín 1)"
"en": "Status effects last 1 fewer {{interval}} (min 1)",
"es": "Los efectos de estado duran 1 {{interval}} menos (mín 1)"
},
"icon": "💪",
"requirements": {

View File

@@ -47,8 +47,8 @@
"es": "Furia Berserker"
},
"description": {
"en": "+50% damage for 3 turns, but +25% damage taken",
"es": "+50% de daño durante 3 turnos, pero +25% de daño recibido"
"en": "+50% damage for 3 {{intervals_plural}}, but +25% damage taken",
"es": "+50% de daño durante 3 {{intervals_plural}}, pero +25% de daño recibido"
},
"icon": "🔥",
"stat_requirement": "strength",
@@ -131,8 +131,8 @@
"es": "Hoja Envenenada"
},
"description": {
"en": "80% damage + poison (3 dmg/turn for 4 turns)",
"es": "80% de daño + veneno (3 de daño/turno durante 4 turnos)"
"en": "80% damage + poison (5% max HP/{{interval}} for 4 {{intervals_plural}})",
"es": "80% de daño + veneno (5% vida máx/{{interval}} durante 4 {{intervals_plural}})"
},
"icon": "🧪",
"stat_requirement": "agility",
@@ -142,7 +142,7 @@
"stamina_cost": 6,
"effects": {
"damage_multiplier": 0.8,
"poison_damage": 3,
"poison_percent": 0.05,
"poison_duration": 4
}
},
@@ -172,8 +172,8 @@
"es": "Fortificar"
},
"description": {
"en": "Reduce incoming damage by 60% for 2 turns",
"es": "Reduce el daño recibido en un 60% durante 2 turnos"
"en": "Reduce incoming damage by 60% for 2 {{intervals_plural}}",
"es": "Reduce el daño recibido en un 60% durante 2 {{intervals_plural}}"
},
"icon": "🛡️",
"stat_requirement": "endurance",
@@ -212,8 +212,8 @@
"es": "Piel de Hierro"
},
"description": {
"en": "Immune to status effects for 3 turns",
"es": "Inmune a efectos de estado durante 3 turnos"
"en": "Immune to status effects for 3 {{intervals_plural}}",
"es": "Inmune a efectos de estado durante 3 {{intervals_plural}}"
},
"icon": "🪨",
"stat_requirement": "endurance",
@@ -327,6 +327,67 @@
"buff_duration": 2,
"enemy_miss": true
}
},
"rabid_bite": {
"name": {
"en": "Rabid Bite",
"es": "Mordedura Rabiosa"
},
"description": {
"en": "A vicious bite that can infect the target with poison",
"es": "Una mordedura feroz que puede infectar al objetivo con veneno"
},
"icon": "🦷",
"stat_requirement": "agility",
"stat_threshold": 0,
"level_requirement": 1,
"cooldown": 4,
"stamina_cost": 0,
"effects": {
"damage_multiplier": 1.2,
"poison_percent": 0.04,
"poison_duration": 3
}
},
"howl": {
"name": {
"en": "Howl",
"es": "Aullido"
},
"description": {
"en": "Increases damage by 50% for 3 {{intervals_plural}}",
"es": "Aumenta el daño en un 50% durante 3 {{intervals_plural}}"
},
"icon": "🐺",
"stat_requirement": "strength",
"stat_threshold": 0,
"level_requirement": 1,
"cooldown": 8,
"stamina_cost": 0,
"effects": {
"buff": "berserker_rage",
"buff_duration": 3,
"damage_bonus": 0.5
}
},
"bandage_self": {
"name": {
"en": "Bandage Self",
"es": "Vendarse"
},
"description": {
"en": "Restores 25% of maximum HP",
"es": "Restaura el 25% de la vida máxima"
},
"icon": "🩹",
"stat_requirement": "intellect",
"stat_threshold": 0,
"level_requirement": 1,
"cooldown": 6,
"stamina_cost": 0,
"effects": {
"heal_percent": 0.25
}
}
}
}

View File

@@ -0,0 +1,76 @@
{
"effects": {
"poison": {
"icon": "🧪",
"name": {
"en": "Poison",
"es": "Veneno"
},
"description": {
"en": "Deals damage each {{interval}}",
"es": "Inflige daño cada {{interval}}"
},
"type": "damage"
},
"stun": {
"icon": "💫",
"name": {
"en": "Stunned",
"es": "Aturdido"
},
"description": {
"en": "Cannot act this {{interval}}",
"es": "No puede actuar este {{interval}}"
},
"type": "debuff"
},
"analyzed": {
"icon": "🔍",
"name": {
"en": "Analyzed",
"es": "Analizado"
},
"description": {
"en": "Weakness exposed, vulnerable to Exploit Weakness",
"es": "Debilidad expuesta, vulnerable a Explotar Debilidad"
},
"type": "debuff"
},
"bleeding": {
"icon": "🩸",
"name": {
"en": "Bleeding",
"es": "Sangrado"
},
"description": {
"en": "Losing blood each {{interval}}",
"es": "Pierde sangre cada {{interval}}"
},
"type": "damage"
},
"burning": {
"icon": "🔥",
"name": {
"en": "Burning",
"es": "Ardiendo"
},
"description": {
"en": "Takes fire damage each {{interval}}",
"es": "Recibe daño de fuego cada {{interval}}"
},
"type": "damage"
},
"regeneration": {
"icon": "💚",
"name": {
"en": "Regeneration",
"es": "Regeneración"
},
"description": {
"en": "Recovers HP every {{interval}}",
"es": "Recupera PS cada {{interval}}"
},
"type": "buff"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

BIN
images/npcs/test_boss.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -424,6 +424,9 @@ function Game() {
try {
const response = await api.post('/api/game/pvp/action', { action })
actions.setMessage(response.data.message || 'Action performed!')
if (response.data.equipment) {
actions.updateEquipment(response.data.equipment)
}
// We don't need to fetchGameData here because the websocket update will handle it?
// The user said: "The timer is also not updating correctly, it should grab the latest data from the websocket update or from the action call."
// So we should probably update state from response if possible, OR fetch.
@@ -504,6 +507,8 @@ function Game() {
onUncraft={(uniqueItemId: string, inventoryId: number, quantity?: number) => actions.handleUncraft(Number(uniqueItemId), inventoryId, quantity)}
failedActionItemId={state.failedActionItemId}
quests={state.quests}
craftedItemResult={state.craftedItemResult}
onCloseCraftedItemResult={() => actions.setCraftedItemResult(null)}
/>
)}
</div>

View File

@@ -82,14 +82,14 @@ export const ItemStatBadges = ({ item }: ItemStatBadgesProps) => {
🏋 +{stats.endurance_bonus} {t('stats.end')}
</span>
)}
{(stats.hp_bonus) && (
{(stats.max_hp) && (
<span className="stat-badge health">
+{stats.hp_bonus} {t('stats.hpMax')}
+{stats.max_hp} {t('stats.hpMax')}
</span>
)}
{(stats.stamina_bonus) && (
{(stats.max_stamina) && (
<span className="stat-badge stamina">
+{stats.stamina_bonus} {t('stats.stmMax')}
+{stats.max_stamina} {t('stats.stmMax')}
</span>
)}

View File

@@ -5,6 +5,8 @@ import api from '../../services/api';
import { GameModal } from './GameModal';
import { GameProgressBar } from '../common/GameProgressBar';
import { GameButton } from '../common/GameButton';
import { GameTooltip } from '../common/GameTooltip';
import { EffectBadge } from './EffectBadge';
import './CharacterSheet.css';
interface CharacterSheetProps {
@@ -75,6 +77,7 @@ interface CharacterSheetData {
used_points: number;
all_perks: PerkData[];
};
status_effects: any[];
character: {
name: string;
level: number;
@@ -149,7 +152,7 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
);
}
const { base_stats, derived_stats, skills, perks, character } = data;
const { base_stats, derived_stats, skills, perks, character, status_effects } = data;
const renderStatsTab = () => (
<div className="cs-stats-tab">
@@ -186,6 +189,27 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
/>
</div>
{status_effects && status_effects.length > 0 && (
<div className="cs-status-effects" style={{ marginBottom: '1.5rem' }}>
<h5 style={{ margin: '0 0 0.5rem 0', color: '#ffb94a' }}>{t('characterSheet.activeEffects', 'Active Effects')}</h5>
<div style={{ display: 'flex', gap: '5px', flexWrap: 'wrap' }}>
{status_effects.map((e: any) => (
<GameTooltip key={e.effect_name || e.id} content={`${getTranslatedText(e.description, { interval: t('stats.interval_minute'), intervals_plural: t('stats.intervals_minute') })} (${e.ticks_remaining} ${t('game.ticksRemaining', 'ticks left')})`}>
<div style={{ display: 'inline-block' }}>
<EffectBadge effect={{
name: e.name || e.effect_name,
icon: e.icon,
type: e.type || (e.damage_per_tick > 0 ? 'damage' : 'buff'),
damage_per_tick: e.damage_per_tick,
ticks: e.ticks_remaining
}} />
</div>
</GameTooltip>
))}
</div>
</div>
)}
{base_stats.unspent_points > 0 && (
<div className="cs-unspent-badge">
<span></span> {base_stats.unspent_points} {t('characterSheet.pointsAvailable', 'points available')}
@@ -273,7 +297,7 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
<span className="cs-skill-badge locked"><span>🔒</span></span>
)}
</div>
<p className="cs-skill-desc">{getTranslatedText(skill.description)}</p>
<p className="cs-skill-desc">{getTranslatedText(skill.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</p>
<div className="cs-skill-meta">
<span className="cs-skill-tag"><span></span> {skill.stamina_cost}</span>
<span className="cs-skill-tag"><span>🔄</span> {skill.cooldown}t</span>
@@ -313,7 +337,7 @@ export function CharacterSheet({ onClose, onSpendPoint }: CharacterSheetProps) {
<span className="cs-perk-icon">{perk.icon}</span>
<div className="cs-perk-title-block">
<span className="cs-perk-name">{getTranslatedText(perk.name)}</span>
<p className="cs-perk-desc">{getTranslatedText(perk.description)}</p>
<p className="cs-perk-desc">{getTranslatedText(perk.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</p>
</div>
{perk.owned ? (
<span className="cs-perk-status owned"><span></span> {t('characterSheet.owned', 'Owned')}</span>

View File

@@ -132,7 +132,10 @@ export const Combat: React.FC<CombatProps> = ({
opponentName: isPvP
? (initialCombatData?.pvp_combat?.is_attacker ? initialCombatData?.pvp_combat?.defender?.username : initialCombatData?.pvp_combat?.attacker?.username)
: undefined,
turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining
turnTimeRemaining: initialCombatData?.pvp_combat?.time_remaining ?? initialCombatData?.turn_time_remaining,
npcEffects: initialCombatData?.combat?.npc_effects || [],
playerEffects: initialCombatData?.player_effects || [],
npcIntent: initialCombatData?.combat?.npc_intent
});
const [animState, setAnimState] = useState<AnimationState>({
@@ -158,6 +161,8 @@ export const Combat: React.FC<CombatProps> = ({
const pendingPlayerHpRef = useRef<{ hp: number; max_hp: number } | null>(null);
// Store server player XP to apply when XP floating text appears
const pendingPlayerXpRef = useRef<{ xp: number; level: number } | null>(null);
// Store server equipment to apply when attack/hit animations occur
const pendingEquipmentRef = useRef<any>(null);
// Update queueRef
useEffect(() => {
@@ -284,6 +289,7 @@ export const Combat: React.FC<CombatProps> = ({
yourTurn: newYourTurn !== undefined ? newYourTurn : prev.yourTurn,
round: initialCombatData?.combat?.round ?? prev.round,
turnTimeRemaining: newTimeRemaining !== undefined ? newTimeRemaining : prev.turnTimeRemaining,
npcIntent: initialCombatData?.combat?.npc_intent ?? prev.npcIntent,
// Sync HP for PVP from WebSocket updates
...(isPvP && newPlayerHp !== undefined ? { playerHp: newPlayerHp } : {}),
...(isPvP && newNpcHp !== undefined ? { npcHp: newNpcHp } : {})
@@ -411,14 +417,17 @@ export const Combat: React.FC<CombatProps> = ({
// Apply server player HP when floating text appears
if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({
...prev,
playerHp: hp,
playerMaxHp: max_hp
}));
setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null;
}
// Apply pending equipment update (durability loss from being hit)
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
}
break;
@@ -436,6 +445,18 @@ export const Combat: React.FC<CombatProps> = ({
triggerAnim('shaking', 500);
if (data.damage) {
addFloatingText(`-${data.damage}!`, 'crit', 'player');
if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null;
}
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
}
break;
@@ -502,13 +523,28 @@ export const Combat: React.FC<CombatProps> = ({
// ── Skill messages ──
case 'skill_attack':
triggerAnim('playerAttacking');
triggerAnim('npcHit', 300);
const target_origin = origin === 'enemy' ? 'player' : 'enemy';
triggerAnim(origin === 'enemy' ? 'enemyAttacking' : 'playerAttacking');
triggerAnim(origin === 'enemy' ? 'playerHit' : 'npcHit', 300);
if (data.damage) {
const label = data.hits > 1
? `${data.skill_icon || '⚔️'} -${data.damage} (x${data.hits})`
: `${data.skill_icon || '⚔️'} -${data.damage}`;
addFloatingText(label, 'damage', 'enemy');
addFloatingText(label, 'damage', target_origin);
if (target_origin === 'player') {
if (pendingPlayerHpRef.current) {
const { hp, max_hp } = pendingPlayerHpRef.current;
setLocalCombatState(prev => ({ ...prev, playerHp: hp, playerMaxHp: max_hp }));
updatePlayerState({ hp, max_hp });
pendingPlayerHpRef.current = null;
}
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
}
}
break;
@@ -572,6 +608,12 @@ export const Combat: React.FC<CombatProps> = ({
} else if (messageQueue.length === 0 && isProcessingQueue) {
// Queue just finished processing
setIsProcessingQueue(false);
// Apply pending equipment updates (durability loss etc.) after ALL animations finish
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
}
}, [messageQueue, processQueue, isProcessingQueue]);
@@ -596,13 +638,23 @@ export const Combat: React.FC<CombatProps> = ({
npcMaxHp: data.combat.npc_max_hp,
turn: data.combat.turn,
round: data.combat.round,
npcName: resolveName(data.combat.npc_name) || prev.npcName
npcName: resolveName(data.combat.npc_name) || prev.npcName,
npcEffects: data.combat.npc_effects || [],
playerEffects: (data as any).player_effects || [],
npcIntent: data.combat.npc_intent
}));
} else if (data.combat_over && data.player_won) {
} else if (data.combat_over && data.player_won === true && action !== 'flee') {
// Apply any remaining pending data on victory
if (pendingEquipmentRef.current) {
updatePlayerState({ equipment: pendingEquipmentRef.current });
pendingEquipmentRef.current = null;
}
// Combat ended with victory but data.combat is null - set enemy HP to 0
setLocalCombatState(prev => ({
...prev,
npcHp: 0
npcHp: 0,
npcEffects: [],
playerEffects: []
}));
}
@@ -611,8 +663,13 @@ export const Combat: React.FC<CombatProps> = ({
pendingPlayerHpRef.current = { hp: data.player.hp, max_hp: data.player.max_hp };
// Store player XP to apply when xp_gain message is processed
pendingPlayerXpRef.current = { xp: data.player.xp, level: data.player.level };
refreshCharacters();
}
if (data.equipment) {
pendingEquipmentRef.current = data.equipment;
}
refreshCharacters();
}
} catch (err) {
console.error(err);
@@ -753,12 +810,17 @@ export const Combat: React.FC<CombatProps> = ({
npcMaxHp: data.combat.npc_max_hp,
turn: data.combat.turn,
round: data.combat.round,
npcName: resolveName(data.combat.npc_name) || prev.npcName
npcName: resolveName(data.combat.npc_name) || prev.npcName,
npcEffects: data.combat.npc_effects || [],
playerEffects: (data as any).player_effects || [],
npcIntent: data.combat.npc_intent
}));
} else if (data.combat_over && data.player_won) {
} else if (data.combat_over && data.player_won === true) {
setLocalCombatState(prev => ({
...prev,
npcHp: 0
npcHp: 0,
npcEffects: [],
playerEffects: []
}));
}

View File

@@ -558,3 +558,35 @@
height: 100%;
transition: width 0.3s ease-out;
}
/* Combat Status Effect Badges */
.combat-effects-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.combat-effect-badge {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 6px;
font-size: 0.7rem;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
clip-path: var(--game-clip-path-sm);
}
.combat-effect-badge.effect-buff {
background: rgba(76, 175, 80, 0.3);
border: 1px solid rgba(76, 175, 80, 0.5);
color: #81c784;
}
.combat-effect-badge.effect-debuff {
background: rgba(220, 53, 69, 0.3);
border: 1px solid rgba(220, 53, 69, 0.5);
color: #ef9a9a;
}

View File

@@ -15,6 +15,14 @@ export interface FloatingText {
timestamp: number;
}
export interface CombatEffect {
name: string | Record<string, string>;
icon: string;
ticks_remaining: number;
type?: string; // 'buff', 'debuff', 'damage'
description?: string | Record<string, string>;
}
export interface CombatState {
inCombat: boolean;
turn: 'player' | 'enemy' | 'attacker' | 'defender';
@@ -31,6 +39,9 @@ export interface CombatState {
round: number;
isPvP?: boolean;
opponentName?: string;
npcEffects?: CombatEffect[];
playerEffects?: CombatEffect[];
npcIntent?: string;
}
export interface CombatActionResponse {
@@ -47,6 +58,7 @@ export interface CombatActionResponse {
level: number;
};
winner_id?: string;
equipment?: any;
}
export interface AnimationState {

View File

@@ -8,6 +8,7 @@ import './CombatEffects.css';
import { GameProgressBar } from '../common/GameProgressBar';
import { GameButton } from '../common/GameButton';
import { GameDropdown } from '../common/GameDropdown';
import { GameTooltip } from '../common/GameTooltip';
import api from '../../services/api';
interface CombatViewProps {
@@ -122,6 +123,20 @@ export const CombatView: React.FC<CombatViewProps> = ({
}
}, [state.messages]);
const getIntentDisplay = (intent: string) => {
switch (intent) {
case 'defend': return { icon: '🛡️', text: t('combat.intents.defend', 'Defending') };
case 'flee': return { icon: '🏃', text: t('combat.intents.flee', 'Fleeing') };
case 'buff': return { icon: '✨', text: t('combat.intents.buff', 'Buffing') };
case 'attack': return { icon: '⚔️', text: t('combat.intents.attack', 'Attacking') };
case 'charging_attack': return { icon: '⚠️', text: t('combat.intents.charging', 'Charging Attack!') };
default:
// For skills like bandage_self etc.
const skillName = intent.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
return { icon: '🌀', text: t(`combat.intents.${intent}`, skillName) };
}
};
return (
<div className="combat-container">
@@ -231,6 +246,31 @@ export const CombatView: React.FC<CombatViewProps> = ({
height="10px"
labelAlignment="right"
/>
{/* Enemy Intent */}
{!state.isPvP && state.npcIntent && !combatResult && (
<div style={{ marginTop: '4px', fontSize: '0.85rem', color: '#ffcc00', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: '4px', fontStyle: 'italic' }}>
<span>{getIntentDisplay(state.npcIntent).icon}</span>
<span>{t('combat.intents.label', 'Next move:')} {getIntentDisplay(state.npcIntent).text}</span>
</div>
)}
{/* Enemy Status Effects */}
{state.npcEffects && state.npcEffects.length > 0 && (
<div className="combat-effects-row">
{state.npcEffects.map((eff, i) => (
<GameTooltip key={i} content={
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{eff.icon} {getTranslatedText(eff.name)}</div>
<div style={{ fontSize: '0.8rem', color: '#aaa' }}>{getTranslatedText(eff.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
<div style={{ fontSize: '0.75rem', color: '#888', marginTop: '2px' }}>{t('combat.log.turns_remaining', { turns: eff.ticks_remaining })}</div>
</div>
}>
<span className="combat-effect-badge effect-debuff">
{eff.icon} {eff.ticks_remaining}
</span>
</GameTooltip>
))}
</div>
)}
</div>
{/* Player HP (Right) */}
@@ -245,6 +285,24 @@ export const CombatView: React.FC<CombatViewProps> = ({
align="right"
labelAlignment="left"
/>
{/* Player Active Buffs/Effects */}
{state.playerEffects && state.playerEffects.length > 0 && (
<div className="combat-effects-row" style={{ justifyContent: 'flex-end' }}>
{state.playerEffects.map((eff, i) => (
<GameTooltip key={i} content={
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>{eff.icon} {getTranslatedText(eff.name)}</div>
<div style={{ fontSize: '0.8rem', color: '#aaa' }}>{getTranslatedText(eff.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
<div style={{ fontSize: '0.75rem', color: '#888', marginTop: '2px' }}>{t('combat.log.turns_remaining', { turns: eff.ticks_remaining })}</div>
</div>
}>
<span className={`combat-effect-badge ${eff.type === 'damage' ? 'effect-debuff' : 'effect-buff'}`}>
{eff.icon} {eff.ticks_remaining}
</span>
</GameTooltip>
))}
</div>
)}
</div>
</div>
@@ -261,17 +319,27 @@ export const CombatView: React.FC<CombatViewProps> = ({
)}
{!combatResult && (
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', width: '100%', maxWidth: '400px', margin: '0 auto' }}>
<div className="combat-actions-group" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', width: '100%', maxWidth: '500px', margin: '0 auto' }}>
<GameButton
variant="danger"
onClick={() => onAction('attack')}
disabled={isProcessing || !state.yourTurn}
>
👊 {t('combat.actions.attack')}
</GameButton>
<GameButton
variant="secondary"
onClick={() => onAction('defend')}
disabled={isProcessing || !state.yourTurn}
>
🛡 {t('combat.actions.defend')}
</GameButton>
<AbilitiesDropdown
onAction={onAction}
disabled={isProcessing || !state.yourTurn}
playerStamina={playerStamina}
/>
@@ -325,10 +393,13 @@ export const CombatView: React.FC<CombatViewProps> = ({
case 'enemy_miss': text = t('combat.log.enemy_miss'); break;
case 'victory': text = t('combat.victory'); className += " text-success bold"; break;
case 'player_defeated': text = t('combat.defeat'); className += " text-danger bold"; break;
case 'flee_success': text = t('combat.flee.success'); break;
case 'flee_fail': text = t('combat.flee.fail'); break;
case 'item_broken': text = t('combat.item_broken', { item: getTranslatedText(msg.data?.item_name) }); break;
case 'xp_gain': text = t('combat.log.xp_gain', { xp: msg.data?.xp }); className += " text-warning"; break;
case 'flee_success': text = t('combat.log.flee_success'); break;
case 'flee_fail':
text = t('combat.log.flee_fail');
className += " text-danger";
break;
case 'item_broken': text = t('combat.log.item_broken', { item: getTranslatedText(msg.data?.item_name), emoji: msg.data?.emoji || '' }); break;
case 'xp_gain': text = t('combat.log.xp_gain', { amount: msg.data?.amount }); className += " text-warning"; break;
case 'damage':
if (msg.origin === 'enemy') {
text = t('combat.log.enemy_attack', { damage: msg.data?.damage || 0 });
@@ -340,7 +411,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
case 'text': text = getTranslatedText(msg.data?.text) || ""; break;
case 'item_used':
text = t('combat.log.item_used', { item: getTranslatedText(msg.data?.item_name) || '' });
if (msg.data?.effects) text += getTranslatedText(msg.data.effects); // Append effects string if backend still sends it
if (msg.data?.effects) text += getTranslatedText(msg.data.effects);
className += " text-info";
break;
case 'effect_applied':
@@ -350,7 +421,121 @@ export const CombatView: React.FC<CombatViewProps> = ({
});
className += " text-warning";
break;
default: text = msg.type;
// ── Skill messages ──
case 'skill_attack': {
const hitsText = msg.data?.hits > 1 ? ` (x${msg.data.hits})` : '';
text = t('combat.log.skill_attack', {
skill_icon: msg.data?.skill_icon || '⚔️',
skill_name: getTranslatedText(msg.data?.skill_name) || '',
damage: msg.data?.damage || 0,
hits_text: hitsText
});
break;
}
case 'skill_heal':
text = t('combat.log.skill_heal', {
skill_icon: msg.data?.skill_icon || '💚',
skill_name: getTranslatedText(msg.data?.skill_name) || '',
heal: msg.data?.heal || 0
});
className += " text-success";
break;
case 'skill_buff':
text = t('combat.log.skill_buff', {
skill_icon: msg.data?.skill_icon || '🛡️',
skill_name: getTranslatedText(msg.data?.skill_name) || ''
});
className += " text-info";
break;
case 'skill_effect':
text = msg.data?.message || '';
className += " text-info";
break;
case 'skill_analyze':
text = t('combat.log.skill_analyze', { skill_icon: msg.data?.skill_icon || '🔍' });
className += " text-info";
break;
// ── Combat reactions ──
case 'combat_crit':
text = t('combat.log.combat_crit');
className += " text-warning bold";
break;
case 'combat_dodge':
text = t('combat.log.combat_dodge');
className += " text-success";
break;
case 'combat_block':
text = t('combat.log.combat_block');
className += " text-success";
break;
case 'damage_reduced':
text = t('combat.log.damage_reduced', { reduction: msg.data?.reduction || 0 });
className += " text-info";
break;
case 'player_defend':
text = t('combat.log.defend');
className += " text-info bold";
break;
// ── Enemy actions ──
case 'enemy_enraged':
text = t('combat.log.enemy_enraged', { npc_name: getTranslatedText(msg.data?.npc_name) || t('common.enemy') });
className += " text-danger bold";
break;
case 'enemy_defend':
text = t('combat.log.enemy_defend', { heal: msg.data?.heal || 0 });
className += " text-danger";
break;
case 'enemy_special':
text = t('combat.log.enemy_special', { damage: msg.data?.damage || 0 });
className += " text-danger bold";
break;
// ── Status effects ──
case 'effect_damage':
if (msg.origin === 'enemy') {
text = t('combat.log.effect_damage_npc', { damage: msg.data?.damage || 0 });
} else {
text = t('combat.log.effect_damage', { damage: msg.data?.damage || 0 });
}
className += " text-danger";
break;
case 'effect_bleeding':
text = t('combat.log.effect_bleeding', { damage: msg.data?.damage || 0 });
className += " text-danger";
break;
case 'effect_heal':
text = t('combat.log.effect_heal', { heal: msg.data?.heal || 0 });
className += " text-success";
break;
// ── Items ──
case 'weapon_broke':
text = t('combat.log.weapon_broke', { item_name: getTranslatedText(msg.data?.item_name) || '' });
className += " text-danger";
break;
case 'item_heal':
text = t('combat.log.item_heal', { heal: msg.data?.heal || 0 });
className += " text-success";
break;
case 'item_restore':
text = t('combat.log.item_restore', { amount: msg.data?.amount || 0, stat: msg.data?.stat || '' });
className += " text-info";
break;
case 'item_damage':
text = t('combat.log.item_damage', { item: getTranslatedText(msg.data?.item_name) || '', damage: msg.data?.damage || 0 });
break;
// ── Outcomes ──
case 'level_up':
text = t('combat.log.level_up', { new_level: msg.data?.new_level || 0 });
className += " text-warning bold";
break;
case 'died':
text = t('combat.log.died');
className += " text-danger bold";
break;
case 'quest_update':
text = msg.data?.message || '';
className += " text-info";
break;
default: text = msg.data?.message || msg.type;
}
}
const time = msg.timestamp || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
@@ -385,6 +570,7 @@ export const CombatView: React.FC<CombatViewProps> = ({
interface SkillInfo {
id: string;
name: any;
description: any;
icon: string;
stamina_cost: number;
cooldown: number;
@@ -429,7 +615,7 @@ const AbilitiesDropdown: React.FC<{
disabled={disabled}
style={{ width: '100%' }}
>
{t('combat.actions.abilities', 'Abilities')}
{t('combat.actions.abilities')}
</GameButton>
{open && skills.length > 0 && (
<GameDropdown
@@ -443,8 +629,17 @@ const AbilitiesDropdown: React.FC<{
const isSkillDisabled = disabled || onCooldown || notEnoughStamina;
return (
<GameTooltip key={s.id} content={
<div>
<div style={{ fontWeight: 600, marginBottom: '3px' }}>{s.icon} {getTranslatedText(s.name)}</div>
<div style={{ fontSize: '0.8rem', color: '#ccc', marginBottom: '4px' }}>{getTranslatedText(s.description, { interval: t('stats.interval_turn'), intervals_plural: t('stats.intervals_turn') })}</div>
<div style={{ fontSize: '0.75rem', color: '#f0c040', display: 'flex', gap: '8px' }}>
<span> {s.stamina_cost} {t('combat.stamina', 'Stamina')}</span>
<span> {t('combat.cooldown_turns', { turns: s.cooldown })}</span>
</div>
</div>
}>
<GameButton
key={s.id}
variant="secondary"
size="sm"
onClick={() => handleUse(s.id)}
@@ -455,12 +650,13 @@ const AbilitiesDropdown: React.FC<{
<span style={{ fontSize: '1rem' }}>{s.icon}</span>
<span style={{ flex: 1, textAlign: 'left', color: isSkillDisabled ? '#808090' : '#d0d0e0' }}>{getTranslatedText(s.name)}</span>
{onCooldown ? (
<span style={{ color: '#e53e3e', fontSize: '0.65rem', fontWeight: 'bold' }}> {s.current_cooldown}T</span>
<span style={{ color: '#ff9f43', fontSize: '0.7rem', fontWeight: 'bold' }}> {s.current_cooldown}</span>
) : (
<span style={{ color: notEnoughStamina ? '#e53e3e' : '#a0a0b0', fontSize: '0.65rem' }}>{s.stamina_cost}</span>
<span style={{ color: notEnoughStamina ? '#e53e3e' : '#f0c040', fontSize: '0.7rem', fontWeight: 600 }}>{s.stamina_cost}</span>
)}
</div>
</GameButton>
</GameTooltip>
);
})}
</GameDropdown>

View File

@@ -24,7 +24,7 @@ export const EffectBadge: React.FC<EffectBadgeProps> = ({ effect }) => {
: getTranslatedText(effect.name);
return (
<span className={`stat-badge ${badgeClass}`}>
<span className={`stat-badge ${badgeClass}`} style={{ padding: '2px 6px', fontSize: '0.75rem', lineHeight: '1' }}>
{effect.icon}
{effect.damage_per_tick ? (
<>

View File

@@ -68,6 +68,8 @@ interface LocationViewProps {
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
failedActionItemId: string | number | null
quests: { active: any[], available: any[] }
craftedItemResult: any | null
onCloseCraftedItemResult: () => void
}
function LocationView({
@@ -90,6 +92,8 @@ function LocationView({
craftCategoryFilter,
profile,
quests,
craftedItemResult,
onCloseCraftedItemResult,
onInitiateCombat,
onInitiatePvP,
@@ -810,6 +814,8 @@ function LocationView({
onCraft={onCraft}
onRepair={onRepair}
onUncraft={onUncraft}
craftedItemResult={craftedItemResult}
onCloseCraftedItemResult={onCloseCraftedItemResult}
/>
)
}

View File

@@ -11,6 +11,7 @@ import { GameButton } from '../common/GameButton'
import { GameItemCard } from '../common/GameItemCard'
import { GameDropdown } from '../common/GameDropdown'
import { useAudio } from '../../contexts/AudioContext'
import { EffectBadge } from './EffectBadge'
interface PlayerSidebarProps {
playerState: PlayerState
@@ -140,14 +141,18 @@ function PlayerSidebar({
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{t('stats.hp')}
<div className="status-indicators" style={{ display: 'flex', gap: '5px' }}>
{playerState.status_effects?.filter((e: any) => e.damage_per_tick !== 0).map((e: any) => (
<span key={e.id} className={`stat-indicator ${e.damage_per_tick > 0 ? 'negative' : 'positive'}`} style={{
color: e.damage_per_tick > 0 ? '#ff6b6b' : '#4caf50',
fontSize: '0.85rem',
fontWeight: 'bold'
}}>
{e.damage_per_tick > 0 ? `-${e.damage_per_tick}` : `+${Math.abs(e.damage_per_tick)}`}/t ({e.ticks_remaining})
</span>
{playerState.status_effects?.map((e: any) => (
<GameTooltip key={e.effect_name || e.id} content={`${getTranslatedText(e.description, { interval: state?.combatState?.inCombat ? t('stats.interval_turn') : t('stats.interval_minute'), intervals_plural: state?.combatState?.inCombat ? t('stats.intervals_turn') : t('stats.intervals_minute') })} (${e.ticks_remaining} ${t('game.ticksRemaining', 'ticks left')})`}>
<div style={{ display: 'inline-block' }}>
<EffectBadge effect={{
name: e.name || e.effect_name,
icon: e.icon,
type: e.type || (e.damage_per_tick > 0 ? 'damage' : 'buff'),
damage_per_tick: e.damage_per_tick,
ticks: e.ticks_remaining
}} />
</div>
</GameTooltip>
))}
</div>
</div>

View File

@@ -28,6 +28,8 @@ interface WorkbenchProps {
onCraft: (itemId: number) => void
onRepair: (uniqueItemId: string, inventoryId: number) => void
onUncraft: (uniqueItemId: string, inventoryId: number, quantity?: number) => void
craftedItemResult: any | null
onCloseCraftedItemResult: () => void
}
function Workbench({
@@ -50,12 +52,15 @@ function Workbench({
onSetCraftCategoryFilter,
onCraft,
onRepair,
onUncraft
onUncraft,
craftedItemResult,
onCloseCraftedItemResult
}: WorkbenchProps) {
const { t } = useTranslation()
const [selectedItem, setSelectedItem] = useState<any>(null)
const [salvageQuantity, setSalvageQuantity] = useState<number>(1)
const [showSalvageModal, setShowSalvageModal] = useState<boolean>(false)
// Reset selection when tab changes
useEffect(() => {
@@ -448,10 +453,7 @@ function Workbench({
variant="danger"
disabled={(profile?.stamina || 0) < ((item.stamina_cost || 1) * salvageQuantity)}
onClick={() => {
const confirmMsg = t('crafting.confirmSalvage', { name: getTranslatedText(item.name) })
if (window.confirm(`${confirmMsg} (x${salvageQuantity})`)) {
onUncraft(item.unique_item_id, item.inventory_id, salvageQuantity)
}
setShowSalvageModal(true)
}}
style={{ width: '100%' }}
>
@@ -677,6 +679,99 @@ function Workbench({
</div>
</div>
</div>
{showSalvageModal && selectedItem && (
<GameModal
title={`♻️ ${t('game.salvage')}`}
onClose={() => setShowSalvageModal(false)}
className="salvage-confirm-modal"
>
<div style={{ padding: '1rem', textAlign: 'center' }}>
<p>{t('crafting.confirmSalvage', { name: getTranslatedText(selectedItem.name) })} (x{salvageQuantity})</p>
<div style={{ display: 'flex', gap: '1rem', marginTop: '2rem', justifyContent: 'center' }}>
<GameButton variant="secondary" onClick={() => setShowSalvageModal(false)}>
{t('common.cancel', 'Cancel')}
</GameButton>
<GameButton variant="danger" onClick={() => {
onUncraft(selectedItem.unique_item_id, selectedItem.inventory_id, salvageQuantity)
setShowSalvageModal(false)
}}>
{t('common.confirm', 'Confirm')}
</GameButton>
</div>
</div>
</GameModal>
)}
{/* Crafted Item Feedback Modal */}
{craftedItemResult && (
<GameModal
title={`${t('crafting.successTitle', 'Crafting Successful!')}`}
onClose={onCloseCraftedItemResult}
className="crafted-item-modal"
>
<div style={{ padding: '1rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div className="item-image-thumb" style={{ width: '80px', height: '80px', marginBottom: '1rem' }}>
{craftedItemResult.image_path ? (
<img
src={getAssetPath(craftedItemResult.image_path)}
alt={getTranslatedText(craftedItemResult.name)}
className="item-thumb-img"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const icon = (e.target as HTMLImageElement).nextElementSibling;
if (icon) icon.classList.remove('hidden');
}}
/>
) : null}
<div className={`item-thumb-emoji ${craftedItemResult.image_path ? 'hidden' : ''}`} style={{ fontSize: '3rem' }}>
{craftedItemResult.emoji || '📦'}
</div>
</div>
<h3 style={{ margin: '0 0 0.5rem 0', color: '#ecc94b' }}>
{getTranslatedText(craftedItemResult.name)}
</h3>
{craftedItemResult.tier && (
<span className={`text-tier-${craftedItemResult.tier}`} style={{ marginBottom: '1rem', fontWeight: 'bold' }}>
Tier {craftedItemResult.tier}
</span>
)}
<div className="item-stats" style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap', width: '100%' }}>
{Object.entries(craftedItemResult.unique_item_data?.unique_stats ?? craftedItemResult.unique_item_data ?? craftedItemResult.base_stats ?? craftedItemResult.stats ?? {})
.filter(([k]) => !['id', 'item_id', 'durability', 'max_durability', 'created_at', 'tier'].includes(k))
.map(([key, value]) => {
const icons: Record<string, string> = {
weight_capacity: `⚖️ ${t('game.weight')}`,
volume_capacity: `📦 ${t('game.volume')}`,
armor: `🛡️ ${t('stats.armor')}`,
hp_max: `❤️ ${t('stats.maxHp')}`,
stamina_max: `${t('stats.maxStamina')}`,
damage_min: `⚔️ ${t('stats.damage')} Min`,
damage_max: `⚔️ ${t('stats.damage')} Max`
}
const label = icons[key] || key.replace('_', ' ')
const unit = key.includes('weight') ? 'kg' : key.includes('volume') ? 'L' : ''
return (
<div key={key} className="stat-badge" style={{ background: 'rgba(0,0,0,0.3)', padding: '0.5rem 1rem', borderRadius: '4px', fontSize: '1rem', color: '#ccc' }}>
<span style={{ color: '#aaa' }}>{label}:</span> <span style={{ color: '#fff', fontWeight: 'bold' }}>+{Math.round(Number(value))}{unit}</span>
</div>
)
})}
</div>
<div style={{ marginTop: '2rem' }}>
<GameButton variant="primary" onClick={onCloseCraftedItemResult}>
{t('common.continue', 'Continue')}
</GameButton>
</div>
</div>
</GameModal>
)}
</GameModal>
)
}

View File

@@ -54,6 +54,7 @@ export interface GameEngineState {
uncraftFilter: string
inventoryFilter: string
inventoryCategoryFilter: string
craftedItemResult: any | null
// PvP state
lastSeenPvPAction: string | null
@@ -130,6 +131,7 @@ export interface GameEngineActions {
setInventoryFilter: (filter: string) => void
setInventoryCategoryFilter: (filter: string) => void
toggleCategoryCollapse: (category: string) => void
setCraftedItemResult: (result: any) => void
// WebSocket helpers
refreshLocation: () => Promise<void>
@@ -142,6 +144,7 @@ export interface GameEngineActions {
addNPCToLocation: (npc: any) => void
removeNPCFromLocation: (enemyId: string) => void
updateStatusEffect: (effectName: string | any, remainingTicks: number) => void
updateEquipment: (equipmentData: any) => void
// Quests
updateQuests: (active: any[], available: any[]) => void
@@ -186,6 +189,7 @@ export function useGameEngine(
const [uncraftableItems, setUncraftableItems] = useState<any[]>([])
const [inventoryFilter, setInventoryFilter] = useState<string>('')
const [inventoryCategoryFilter, setInventoryCategoryFilter] = useState<string>('all')
const [craftedItemResult, setCraftedItemResult] = useState<any | null>(null)
const [lastSeenPvPAction, setLastSeenPvPAction] = useState<string | null>(null)
const [_pvpTimeRemaining, _setPvpTimeRemaining] = useState<number | null>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState<MobileMenuState>('none')
@@ -483,7 +487,8 @@ export function useGameEngine(
in_combat: true,
combat_over: false,
player_won: false,
combat: encounter.combat
combat: encounter.combat,
player_effects: encounter.player_effects || []
})
setCombatLog([])
@@ -663,7 +668,8 @@ export function useGameEngine(
mobileHeaderOpen,
locationMessages,
interactableCooldowns,
forceUpdate: _forceUpdate
forceUpdate: _forceUpdate,
craftedItemResult
}
const handleUseItem = async (itemId: string) => {
@@ -779,6 +785,9 @@ export function useGameEngine(
// setMessage('Crafting...') // Loading state ok to keep specific or remove? Let's remove to avoid spam
const response = await api.post('/api/game/craft_item', { item_id: itemId })
addLocationMessage(response.data.message || 'Item crafted!')
if (response.data.item) {
setCraftedItemResult(response.data.item)
}
await refreshWorkbenchData()
} catch (error: any) {
addLocationMessage(error.response?.data?.detail || 'Failed to craft item')
@@ -870,7 +879,8 @@ export function useGameEngine(
in_combat: true,
combat_over: false,
player_won: false,
combat: response.data.combat
combat: response.data.combat,
player_effects: response.data.player_effects || []
})
setEnemyName(response.data.combat.npc_name)
@@ -908,6 +918,10 @@ export function useGameEngine(
response.data.quest_updates.forEach((q: any) => handleQuestUpdate(q))
}
// if (response.data.equipment) {
// setEquipment(response.data.equipment)
// }
return response.data
} catch (error: any) {
setMessage(error.response?.data?.detail || 'Combat action failed')
@@ -941,7 +955,9 @@ export function useGameEngine(
const handlePvPAction = async (action: string, _targetId: number) => {
try {
let payload: any = { action }
if (action.includes(':')) {
if (action.startsWith('skill:')) {
payload = { action: 'skill', skill_id: action.substring(6) }
} else if (action.includes(':')) {
const [act, itemId] = action.split(':')
payload = { action: act, item_id: itemId }
}
@@ -1081,7 +1097,8 @@ export function useGameEngine(
setCombatState({
in_combat: true,
combat_over: false,
combat: combatRes.data.combat
combat: combatRes.data.combat,
player_effects: combatRes.data.player_effects || []
})
// Update enemy name/image state
@@ -1118,6 +1135,12 @@ export function useGameEngine(
if (playerData.max_stamina !== undefined) {
mappedData.max_stamina = playerData.max_stamina
}
if (playerData.status_effects !== undefined) {
mappedData.status_effects = playerData.status_effects
}
if (playerData.equipment !== undefined) {
setEquipment(playerData.equipment)
}
// Update playerState with mapped fields
if (Object.keys(mappedData).length > 0) {
@@ -1259,6 +1282,8 @@ export function useGameEngine(
setUncraftFilter,
setInventoryFilter,
setInventoryCategoryFilter,
setCraftedItemResult,
updateEquipment: (data: any) => setEquipment(data),
// WebSocket helper functions
refreshLocation,
refreshCombat,

View File

@@ -257,7 +257,11 @@
"agi": "AGI",
"end": "END",
"hpMax": "HP max",
"stmMax": "Stm max"
"stmMax": "Stm max",
"interval_turn": "turn",
"intervals_turn": "turns",
"interval_minute": "minute",
"intervals_minute": "minutes"
},
"combat": {
"title": "Combat",
@@ -285,6 +289,14 @@
"yourTurnTimer": "Your Turn ({{time}})",
"enemyTurnTimer": "Enemy Turn",
"waiting": "Waiting for opponent...",
"intents": {
"label": "Next move:",
"defend": "Defending",
"flee": "Fleeing",
"buff": "Buffing",
"attack": "Attacking",
"charging": "Charging Attack!"
},
"messages": {
"combat_start": "Combat started with {{enemy}}!",
"player_attack": "You attack for {{damage}} damage!",
@@ -298,8 +310,11 @@
"defend": "Defend",
"flee": "Flee",
"supplies": "Supplies",
"useItem": "Use Item"
"useItem": "Use Item",
"abilities": "Abilities"
},
"stamina": "Stamina",
"cooldown_turns": "{{turns}} turn cooldown",
"status": {
"attacking": "Attacking...",
"defending": "Bracing for impact...",
@@ -332,15 +347,33 @@
"weapon_broke": "Your {{item_name}} broke!",
"item_broken": "Your {{emoji}} {{item_name}} broke!",
"combat_crit": "CRITICAL HIT!",
"combat_dodge": "You Dodged the attack!",
"combat_block": "You Blocked the attack!",
"combat_dodge": "You dodged the attack!",
"combat_block": "You blocked the attack!",
"xp_gain": "Gained {{amount}} XP",
"flee_success": "You managed to escape!",
"flee_fail": "Failed to escape!",
"defend": "You brace for impact!",
"item_used": "Used {{item}}",
"effect_applied": "Applied {{effect}} to {{target}}",
"item_damage": "{{item}} deals {{damage}} damage!",
"damage_reduced": "Damage reduced by {{reduction}}%"
"damage_reduced": "Damage reduced by {{reduction}}%",
"skill_attack": "{{skill_icon}} {{skill_name}} hits for {{damage}} damage{{hits_text}}",
"skill_heal": "{{skill_icon}} {{skill_name}} heals for {{heal}} HP",
"skill_buff": "{{skill_icon}} {{skill_name}} activated",
"skill_effect": "{{message}}",
"skill_analyze": "{{skill_icon}} Target analyzed!",
"enemy_enraged": "{{npc_name}} is enraged!",
"enemy_defend": "Enemy recovers {{heal}} HP",
"enemy_special": "Enemy uses a special attack for {{damage}} damage!",
"effect_bleeding": "Bleeding for {{damage}} damage",
"effect_heal": "Recovered {{heal}} HP",
"effect_damage": "Took {{damage}} damage from status effects",
"effect_damage_npc": "The enemy took {{damage}} damage from status effects",
"level_up": "Level up! You are now level {{new_level}}!",
"item_heal": "Healed for {{heal}} HP",
"item_restore": "Restored {{amount}} {{stat}}",
"died": "You have been defeated!",
"turns_remaining": "{{turns}} turns remaining"
},
"modal": {
"supplies_title": "Combat Supplies",

View File

@@ -254,8 +254,12 @@
"str": "FUE",
"agi": "AGI",
"end": "RES",
"hpMax": "Vida máx",
"stmMax": "Agua. máx"
"hpMax": "PS máx",
"stmMax": "Ag máx",
"interval_turn": "turno",
"intervals_turn": "turnos",
"interval_minute": "minuto",
"intervals_minute": "minutos"
},
"combat": {
"title": "Combate",
@@ -284,6 +288,14 @@
"yourTurnTimer": "Tu Turno ({{time}})",
"enemyTurnTimer": "Turno del Enemigo",
"waiting": "Esperando al oponente...",
"intents": {
"label": "Próximo movimiento:",
"defend": "Defendiendo",
"flee": "Huyendo",
"buff": "Potenciándose",
"attack": "Atacando",
"charging": "¡Ataque Cargado!"
},
"messages": {
"combat_start": "¡Combate iniciado con {{enemy}}!",
"player_attack": "¡Atacas por {{damage}} de daño!",
@@ -296,8 +308,11 @@
"defend": "Defender",
"flee": "Huir",
"supplies": "Suministros",
"useItem": "Usar Objeto"
"useItem": "Usar Objeto",
"abilities": "Habilidades"
},
"stamina": "Aguante",
"cooldown_turns": "{{turns}} turnos de espera",
"status": {
"attacking": "Atacando...",
"defending": "Preparándose...",
@@ -339,7 +354,24 @@
"item_used": "Usaste {{item}}",
"effect_applied": "Aplicado {{effect}} a {{target}}",
"item_damage": "{{item}} inflige {{damage}} de daño!",
"damage_reduced": "Daño reducido en {{reduction}}%"
"damage_reduced": "Daño reducido en {{reduction}}%",
"skill_attack": "{{skill_icon}} {{skill_name}} golpea por {{damage}} de daño{{hits_text}}",
"skill_heal": "{{skill_icon}} {{skill_name}} cura {{heal}} PS",
"skill_buff": "{{skill_icon}} {{skill_name}} activado",
"skill_effect": "{{message}}",
"skill_analyze": "{{skill_icon}} ¡Objetivo analizado!",
"enemy_enraged": "¡{{npc_name}} está enfurecido!",
"enemy_defend": "El enemigo recupera {{heal}} PS",
"enemy_special": "¡El enemigo usa un ataque especial por {{damage}} de daño!",
"effect_bleeding": "Sangrado por {{damage}} de daño",
"effect_heal": "Recuperaste {{heal}} PS",
"effect_damage": "Recibiste {{damage}} de daño por efectos de estado",
"effect_damage_npc": "El enemigo recibió {{damage}} de daño por efectos de estado",
"level_up": "¡Subiste de nivel! ¡Ahora eres nivel {{new_level}}!",
"item_heal": "Curaste {{heal}} PS",
"item_restore": "Restauraste {{amount}} de {{stat}}",
"died": "¡Has sido derrotado!",
"turns_remaining": "{{turns}} turnos restantes"
},
"modal": {
"supplies_title": "Suministros de Combate",

View File

@@ -7,25 +7,37 @@ export type I18nString = string | { [key: string]: string }
* @param value The value to translate (string or object with language keys)
* @returns The translated string for the current language, or fallback to English/first available
*/
export const getTranslatedText = (value: I18nString | undefined | null): string => {
export const getTranslatedText = (value: I18nString | undefined | null, vars?: Record<string, string | number>): string => {
if (!value) return ''
// If it's already a string, return it
if (typeof value === 'string') return value
let text = typeof value === 'string' ? value : '';
// If it's an object, try to get the current language
const currentLang = i18n.language || 'en'
if (!text && typeof value === 'object') {
const objValue = value as Record<string, string>;
const currentLang = i18n.language || 'en';
// 1. Try current language
if (value[currentLang]) return value[currentLang]
// 2. Try English fallback
if (value['en']) return value['en']
// 3. Return the first available key
const firstKey = Object.keys(value)[0]
if (firstKey) return value[firstKey]
// 4. Fallback empty
return ''
if (objValue[currentLang]) {
text = objValue[currentLang];
}
// 2. Try English fallback
else if (objValue['en']) {
text = objValue['en'];
}
// 3. Return the first available key
else {
const firstKey = Object.keys(objValue)[0];
if (firstKey) text = objValue[firstKey];
}
}
if (!text) return '';
if (vars) {
Object.entries(vars).forEach(([k, v]) => {
text = text.replace(new RegExp(`{{${k}}}`, 'g'), String(v));
});
}
return text;
}