3 Commits

Author SHA1 Message Date
Joan
a8dc8211d5 Pre-menu-integration snapshot: combat, crafting, status effects, gamedata updates 2026-03-11 12:43:23 +01:00
Joan
d5afd28eb9 Refactor: unified combat engine for PvE/PvP
- Create api/services/combat_engine.py with all shared combat logic
- Rewrite combat.py from 2820 to ~600 lines (thin orchestration)
- Fix buff consumption: fortify, berserker_rage, evade, foresight, iron_skin now actually work
- Fix stun: PvE skills now write stun to npc_status_effects
- Fix skill damage: now uses stats.attack_power consistently (includes perks)
- Fix PvPCombatActionRequest: add skill_id field for proper PvP skill support
- Remove dead code: PvP skill/item blocks copy-pasted into PvE endpoint
- Update game_logic.npc_attack to check buff modifiers (dodge, damage reduction, etc.)
2026-02-25 12:10:45 +01:00
Joan
540df02ae7 Pre-combat-refactor: current state with PvP sync, boss setup scripts, combat fixes 2026-02-25 12:00:06 +01:00
45 changed files with 4313 additions and 1641 deletions

26
add_boss.py Normal file
View File

@@ -0,0 +1,26 @@
import json
with open('gamedata/npcs.json', 'r') as f:
data = json.load(f)
if 'test_boss' not in data['npcs']:
data['npcs']['test_boss'] = {
'name': {'en': 'Level 50 Test Boss', 'es': 'Jefe de Prueba Nivel 50'},
'description': {'en': 'A huge terrifying monster.', 'es': 'Un monstruo enorme y aterrador.'},
'emoji': '👹',
'hp_min': 1000,
'hp_max': 1500,
'damage_min': 25,
'damage_max': 45,
'defense': 15,
'xp_reward': 500,
'loot_table': [],
'flee_chance": 0.0,
'status_inflict_chance': 0.5,
'death_message': {'en': 'The boss is defeated.', 'es': 'El jefe ha sido derrotado.'}
}
with open('gamedata/npcs.json', 'w') as f:
json.dump(data, f, indent=2)
print("Boss added.")
else:
print("Boss exists.")

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,169 +741,161 @@ async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func, p
heal = abs(dmg)
npc_healing_received += heal
messages.append(create_combat_message(
"effect_heal", # Check if this message type exists or fallback
"effect_heal",
origin="enemy",
heal=heal,
effect_name=name,
npc_name=npc_def.name
))
elif name in ["berserker_rage", "fortify", "analyzed"]:
pass
# Decrement tick
ticks -= 1
if ticks > 0:
active_effects.append(f"{name}:{dmg}:{ticks}")
except Exception as e:
print(f"Error parsing NPC status: {e}")
# Update NPC active effects
new_status_str = "|".join(active_effects)
if new_status_str != npc_status_str:
await db.update_combat(player_id, {'npc_status_effects': new_status_str})
# Apply Total Damage/Healing
if npc_damage_taken > 0:
npc_hp = max(0, npc_hp - npc_damage_taken)
if npc_healing_received > 0:
npc_hp = min(npc_max_hp, npc_hp + npc_healing_received)
# Update NPC HP in DB
await db.update_combat(player_id, {'npc_hp': npc_hp})
# Check if NPC died from effects
if npc_hp <= 0:
messages.append(create_combat_message(
"victory",
origin="neutral",
npc_name=npc_def.name
))
# Award XP/Loot logic handled in combat route mostly, but we need to signal it.
# Returning true for player_defeated is definitely WRONG here if NPC died.
# The router usually handles "victory" check after action.
# But here this is triggered during NPC turn (which happens after Player turn).
# If NPC dies on its OWN turn, we need to handle it.
# However, typically NPC dies on Player turn.
# If NPC dies from bleeding on its turn, the player wins.
# We need to signal this back to router.
# But the current return signature is (messages, player_defeated).
# We might need to handle the win logic here or update signature.
# For now, let's update HP and let the flow continue.
# Wait, if NPC is dead, it shouldn't attack!
# returning here prevents NPC from attacking if it died from status effects
messages.append(create_combat_message("victory", origin="neutral", npc_name=npc_def.name))
return messages, False
# Parse current intent (stored in DB as string or JSON, assuming simple string for now or we parse it)
current_intent_str = combat.get('npc_intent', 'attack')
# Handle legacy/null
if not current_intent_str:
current_intent_str = 'attack'
intent_type = current_intent_str
intent_parts = current_intent_str.split(':')
intent_type = intent_parts[0]
intent_value = intent_parts[1] if len(intent_parts) > 1 else None
actual_damage = 0
# EXECUTE INTENT
if npc_hp > 0 and not is_stunned: # Only attack if alive and not stunned
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 == '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'
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
# Enrage bonus if NPC is below 30% HP
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
if is_enraged:
npc_damage = int(npc_damage * 1.5)
messages.append(create_combat_message(
"enemy_enraged",
origin="enemy",
npc_name=npc_def.name
))
# Check if player is defending (reduces damage by value%)
player_effects = await db.get_player_effects(player_id)
defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None)
if defending_effect:
reduction = defending_effect.get('value', 50) / 100 # Default 50% reduction
npc_damage = int(npc_damage * (1 - reduction))
messages.append(create_combat_message(
"damage_reduced",
origin="player",
reduction=int(reduction * 100)
))
# Remove defending effect after use
await db.remove_effect(player_id, 'defending')
# Check for dodge
dodged = False
if player_stats and 'dodge_chance' in player_stats:
if random.random() < player_stats['dodge_chance']:
dodged = True
messages.append(create_combat_message(
"combat_dodge",
origin="player"
))
# Prevent damage calculation
actual_damage = 0
new_player_hp = player['hp']
# Check for block (if shield is equipped)
blocked = False
if not dodged and player_stats and player_stats.get('has_shield', False):
if random.random() < player_stats.get('block_chance', 0):
blocked = True
if npc_hp > 0 and not is_stunned:
if intent_type == 'defend':
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(
"combat_block",
origin="player"
"skill_effect", origin="enemy", message=get_game_message('enemy_charging', locale, enemy=get_locale_string(npc_def.name, locale))
))
# Apply blocked effect (damage reduced significantly or nullified)
npc_damage = max(1, int(npc_damage * 0.2)) # Block mitigates 80% damage
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'
if intent_type == 'charging_attack':
npc_damage = int(npc_damage * 2.5)
elif intent_type == 'special':
npc_damage = int(npc_damage * 1.5)
elif intent_type == 'skill' and intent_value:
skill = skills_manager.get_skill(intent_value)
if skill:
if skill.cooldown > 0:
cd_str = f"cd_{skill.id}:0:{skill.cooldown}"
curr_combat = await db.get_active_combat(player_id)
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
new_status = curr_status + f"|{cd_str}" if curr_status else cd_str
await db.update_combat(player_id, {'npc_status_effects': new_status})
effects = skill.effects
if 'heal_percent' in effects:
heal_amount = int(combat['npc_max_hp'] * effects['heal_percent'])
new_npc_hp = min(combat['npc_max_hp'], npc_hp + heal_amount)
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
messages.append(create_combat_message("skill_heal", origin="enemy", heal=heal_amount, skill_icon=skill.icon, skill_name=get_locale_string(skill.name, locale), npc_name=npc_def.name))
npc_damage = 0
if 'buff' in effects:
buff_str = f"{effects['buff']}:0:{effects['buff_duration']}"
curr_combat = await db.get_active_combat(player_id)
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
new_status = curr_status + f"|{buff_str}" if curr_status else buff_str
await db.update_combat(player_id, {'npc_status_effects': new_status})
messages.append(create_combat_message("skill_buff", origin="enemy", skill_name=get_locale_string(skill.name, locale), skill_icon=skill.icon, duration=effects['buff_duration'], npc_name=npc_def.name))
if 'damage_multiplier' not in effects and 'poison_damage' not in effects:
npc_damage = 0
if 'damage_multiplier' in effects:
npc_damage = max(1, int(npc_damage * effects['damage_multiplier']))
from api.services.helpers import calculate_dynamic_status_damage
poison_dmg = calculate_dynamic_status_damage(effects, 'poison', player)
if poison_dmg is not None:
await db.add_effect(player_id=player_id, effect_name="Poison", effect_icon="🧪", effect_type="damage", damage_per_tick=poison_dmg, ticks_remaining=effects.get('poison_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}")
burn_dmg = calculate_dynamic_status_damage(effects, 'burn', player)
if burn_dmg is not None:
await db.add_effect(player_id=player_id, effect_name="Burning", effect_icon="🔥", effect_type="damage", damage_per_tick=burn_dmg, ticks_remaining=effects.get('burn_duration', 3), persist_after_combat=True, source=f"enemy_skill:{skill.id}")
is_enraged = combat['npc_hp'] / combat['npc_max_hp'] < 0.3
if is_enraged and npc_damage > 0:
npc_damage = int(npc_damage * 1.5)
messages.append(create_combat_message("enemy_enraged", origin="enemy", npc_name=npc_def.name))
curr_combat = await db.get_active_combat(player_id)
curr_status = curr_combat.get('npc_status_effects', '') if curr_combat else ''
if 'berserker_rage' in curr_status and npc_damage > 0:
npc_damage = int(npc_damage * 1.5)
if npc_damage > 0:
dodged = False
is_defending = False
player_effects = await db.get_player_effects(player_id)
defending_effect = next((e for e in player_effects if e['effect_name'] == 'defending'), None)
if defending_effect:
is_defending = True
reduction = defending_effect.get('value', 50) / 100
npc_damage = max(1, int(npc_damage * (1 - reduction)))
messages.append(create_combat_message("damage_reduced", origin="player", reduction=int(reduction * 100)))
await db.remove_effect(player_id, 'defending')
buff_dmg_reduction = player_stats.get('buff_damage_reduction', 0.0) if player_stats else 0.0
if buff_dmg_reduction > 0:
npc_damage = max(1, int(npc_damage * (1 - buff_dmg_reduction)))
messages.append(create_combat_message("damage_reduced", origin="player", reduction=int(buff_dmg_reduction * 100)))
buff_dmg_taken_increase = player_stats.get('buff_damage_taken_increase', 0.0) if player_stats else 0.0
if buff_dmg_taken_increase > 0:
npc_damage = int(npc_damage * (1 + buff_dmg_taken_increase))
if player_stats and player_stats.get('buff_guaranteed_dodge', False):
dodged = True
messages.append(create_combat_message("combat_dodge", origin="player"))
await db.remove_effect(player_id, 'evade')
elif player_stats and player_stats.get('buff_enemy_miss', False):
dodged = True
messages.append(create_combat_message("combat_dodge", origin="player"))
elif player_stats and 'dodge_chance' in player_stats and random.random() < player_stats['dodge_chance']:
dodged = True
messages.append(create_combat_message("combat_dodge", origin="player"))
if not dodged and player_stats and player_stats.get('has_shield', False) and random.random() < player_stats.get('block_chance', 0):
messages.append(create_combat_message("combat_block", origin="player"))
npc_damage = max(1, int(npc_damage * 0.2))
if not dodged:
# 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)))
# Still show "armor_absorbed" conceptually for UI logs, though it's % based now
armor_absorbed_visual = npc_damage - actual_damage
else:
actual_damage = max(1, npc_damage - armor_absorbed)
@@ -852,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))
@@ -902,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)

File diff suppressed because it is too large Load Diff

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"])

File diff suppressed because it is too large Load Diff

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

@@ -89,12 +89,13 @@ class PvPCombatInitiateRequest(BaseModel):
class PvPAcknowledgeRequest(BaseModel):
pass # No body needed
combat_id: int
class PvPCombatActionRequest(BaseModel):
action: str # 'attack', 'defend', 'flee', 'use_item'
action: str # 'attack', 'skill', 'flee', 'use_item'
item_id: Optional[str] = None # For use_item action
skill_id: Optional[str] = None # For skill action
# ============================================================================

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()

77
api/setup_test_env.py Normal file
View File

@@ -0,0 +1,77 @@
import asyncio
import json
import time
import os
from database import Database
async def main():
# 1. Update npcs.json to add a test boss
with open('../gamedata/npcs.json', 'r') as f:
data = json.load(f)
if 'test_boss' not in data['npcs']:
data['npcs']['test_boss'] = {
"name": {"en": "Level 50 Test Boss", "es": "Jefe de Prueba Nivel 50"},
"description": {"en": "A huge terrifying monster.", "es": "Un monstruo enorme y aterrador."},
"emoji": "👹",
"hp_min": 1000,
"hp_max": 1500,
"damage_min": 25,
"damage_max": 45,
"defense": 15,
"xp_reward": 500,
"loot_table": [],
"flee_chance": 0.0,
"status_inflict_chance": 0.5,
"death_message": {"en": "The boss is defeated.", "es": "El jefe ha sido derrotado."}
}
with open('../gamedata/npcs.json', 'w') as f:
json.dump(data, f, indent=2)
print("Added 'test_boss' to npcs.json")
db = Database()
await db.connect()
# 2. Get Jocaru
player = await db.fetch_one("SELECT * FROM characters WHERE name ILIKE 'Jocaru'")
if not player:
print("Player Jocaru not found!")
return
pid = player['id']
ploc = player['location_id']
# 3. Give items
items_to_give = [
('reinforced_pack', 1),
('reinforced_bat', 1),
('knife', 1),
('first_aid_kit', 10),
('mystery_pills', 5),
('energy_bar', 10),
('molotov', 5)
]
for item_id, qty in items_to_give:
await db.add_item_to_inventory(pid, item_id, qty)
print("Granted test items and backpack.")
# 4. Give XP to reach lvl 50 if needed
# Level 50 is base + (50 * multiplier) ... the logic is in check_and_apply_level_up
await db.execute("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid", {"pid": pid})
print("Buffed Jocaru to lvl 50 manually.")
# 5. Spawn enemies at player's location
now = time.time()
despawn = now + 86400 # 1 day
enemies_to_spawn = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
for eid in enemies_to_spawn:
await db.execute(
"INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)",
{"nid": eid, "loc": ploc, "start": now, "end": despawn}
)
print(f"Spawned {len(enemies_to_spawn)} enemies at {ploc}")
await db.disconnect()
if __name__ == "__main__":
asyncio.run(main())

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,11 +304,46 @@
],
"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.",
"es": "El deseo de supervivencia del escavador se agota. La supervivencia no tiene misericordia."
}
},
"test_boss": {
"npc_id": "test_boss",
"name": {
"en": "Level 50 Test Boss",
"es": "Jefe de Prueba Nivel 50"
},
"description": {
"en": "A huge terrifying monster.",
"es": "Un monstruo enorme y aterrador."
},
"image_path": "images/npcs/test_boss.webp",
"emoji": "👹",
"hp_min": 1000,
"hp_max": 2000,
"damage_min": 25,
"damage_max": 65,
"defense": 15,
"xp_reward": 500,
"loot_table": [],
"flee_chance": 0.0,
"status_inflict_chance": 0.5,
"skills": [
"howl",
"power_strike",
"crushing_blow"
],
"death_message": {
"en": "The boss is defeated.",
"es": "El jefe ha sido derrotado."
}
}
},
"danger_levels": {

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

@@ -56,8 +56,11 @@ export const GameDropdown: React.FC<GameDropdownProps> = ({
document.addEventListener('mousedown', handleClickOutside);
// Handle scroll to close the dropdown (prevents detached menu and layout shifts)
const handleScroll = () => {
const handleScroll = (event: Event) => {
// Only close if scrolling the main document/window, not a sub-container like combat log
if (event.target === document || event.target === window || event.target === document.documentElement || event.target === document.body) {
onClose();
}
};
window.addEventListener('scroll', handleScroll, true);

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,7 +445,23 @@ 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;
case 'effect_damage':
addFloatingText(`-${data.damage}`, 'damage', origin === 'enemy' ? 'enemy' : 'player');
break;
case 'effect_bleeding':
@@ -498,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;
@@ -568,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]);
@@ -592,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: []
}));
}
@@ -607,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);
@@ -749,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]
if (objValue[currentLang]) {
text = objValue[currentLang];
}
// 2. Try English fallback
if (value['en']) return value['en']
else if (objValue['en']) {
text = objValue['en'];
}
// 3. Return the first available key
const firstKey = Object.keys(value)[0]
if (firstKey) return value[firstKey]
else {
const firstKey = Object.keys(objValue)[0];
if (firstKey) text = objValue[firstKey];
}
}
// 4. Fallback empty
return ''
if (!text) return '';
if (vars) {
Object.entries(vars).forEach(([k, v]) => {
text = text.replace(new RegExp(`{{${k}}}`, 'g'), String(v));
});
}
return text;
}

60
setup_boss.py Normal file
View File

@@ -0,0 +1,60 @@
import json
import asyncio
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
import time
async def main():
# Connect to DB using sqlalchemy
url = "postgresql+asyncpg://admin:password@echoes_of_the_ashes_db:5432/echoesoftheashes"
engine = create_async_engine(url)
try:
async with engine.begin() as conn:
# Get Jocaru ID
res = await conn.execute(text("SELECT id, location_id FROM characters WHERE name ILIKE 'Jocaru'"))
row = res.first()
if not row:
print("Jocaru not found.")
return
pid, loc = row[0], row[1]
print(f"Player Jocaru found (ID {pid}) at {loc}")
# Buff to level 50
await conn.execute(text("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid"), {"pid": pid})
print("Set Jocaru to level 50 metrics.")
# Give items directly via SQL
items = [
('reinforced_pack', 1),
('reinforced_bat', 1),
('combat_knife', 1),
('first_aid_kit', 10),
('mystery_pills', 5),
('energy_bar', 10)
]
for iid, qty in items:
await conn.execute(
text("INSERT INTO inventory (character_id, item_id, quantity) VALUES (:pid, :iid, :qty)"),
{"pid": pid, "iid": iid, "qty": qty}
)
print("Gave items to Jocaru.")
# Spawn enemies
now = time.time()
despawn = now + 86400
enemies = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
for eid in enemies:
await conn.execute(
text("INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)"),
{"nid": eid, "loc": loc, "start": now, "end": despawn}
)
print(f"Spawned {len(enemies)} enemies at {loc}.")
except Exception as e:
print(f"Error accessing DB natively: {e}")
asyncio.run(main())

45
setup_boss.sql Normal file
View File

@@ -0,0 +1,45 @@
-- Buff player and get location into temporary variable
DO $$
DECLARE
player_id INT;
loc_id VARCHAR;
start_ts FLOAT;
end_ts FLOAT;
BEGIN
SELECT id, location_id INTO player_id, loc_id FROM characters WHERE name ILIKE 'Jocaru';
IF NOT FOUND THEN
RAISE NOTICE 'Player Jocaru not found';
RETURN;
END IF;
UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = player_id;
-- Give items
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'reinforced_pack', 1);
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'reinforced_bat', 1);
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'combat_knife', 1);
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'first_aid_kit', 10);
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'mystery_pills', 5);
INSERT INTO inventory (character_id, item_id, quantity) VALUES (player_id, 'energy_bar', 10);
-- Spawn enemies
start_ts := extract(epoch from now());
end_ts := start_ts + 86400;
-- 5 Raider Scouts
FOR i IN 1..5 LOOP
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('raider_scout', loc_id, start_ts, end_ts);
END LOOP;
-- 5 Feral Dogs
FOR i IN 1..5 LOOP
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('feral_dog', loc_id, start_ts, end_ts);
END LOOP;
-- 5 Mutant Rats
FOR i IN 1..5 LOOP
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('mutant_rat', loc_id, start_ts, end_ts);
END LOOP;
-- 2 Test Bosses
FOR i IN 1..2 LOOP
INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES ('test_boss', loc_id, start_ts, end_ts);
END LOOP;
END $$;

42
setup_boss_host.py Normal file
View File

@@ -0,0 +1,42 @@
import asyncio
import time
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
url = "postgresql+asyncpg://admin:password@localhost:5432/echoesoftheashes"
engine = create_async_engine(url)
try:
async with engine.begin() as conn:
res = await conn.execute(text("SELECT id, location_id FROM characters WHERE name ILIKE 'Jocaru'"))
row = res.first()
if not row:
print("Jocaru not found.")
return
pid, loc = row[0], row[1]
print(f"Player Jocaru found (ID {pid}) at {loc}")
await conn.execute(text("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid"), {"pid": pid})
items = [
('reinforced_pack', 1),
('reinforced_bat', 1),
('combat_knife', 1),
('first_aid_kit', 10),
('mystery_pills', 5),
('energy_bar', 10)
]
for iid, qty in items:
await conn.execute(text("INSERT INTO inventory (character_id, item_id, quantity) VALUES (:pid, :iid, :qty)"), {"pid": pid, "iid": iid, "qty": qty})
now = time.time()
despawn = now + 86400
enemies = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
for eid in enemies:
await conn.execute(text("INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)"),
{"nid": eid, "loc": loc, "start": now, "end": despawn})
print(f"Spawned {len(enemies)} enemies at {loc}.")
except Exception as e:
print(f"DB Error: {e}")
asyncio.run(main())

100
setup_test_env.py Normal file
View File

@@ -0,0 +1,100 @@
import asyncio
import json
import time
import os
import random
from api.database import Database
async def main():
# 1. Update npcs.json to add a test boss
with open('gamedata/npcs.json', 'r') as f:
data = json.load(f)
if 'test_boss' not in data['npcs']:
data['npcs']['test_boss'] = {
"name": {"en": "Level 50 Test Boss", "es": "Jefe de Prueba Nivel 50"},
"description": {"en": "A huge terrifying monster.", "es": "Un monstruo enorme y aterrador."},
"emoji": "👹",
"hp_min": 1000,
"hp_max": 1500,
"damage_min": 25,
"damage_max": 45,
"defense": 15,
"xp_reward": 500,
"loot_table": [],
"flee_chance": 0.0,
"status_inflict_chance": 0.5,
"death_message": {"en": "The boss is defeated.", "es": "El jefe ha sido derrotado."}
}
with open('gamedata/npcs.json', 'w') as f:
json.dump(data, f, indent=2)
print("Added 'test_boss' to npcs.json")
db = Database()
await db.connect()
# 2. Get Jocaru
player = await db.fetch_one("SELECT * FROM characters WHERE name ILIKE 'Jocaru'")
if not player:
print("Player Jocaru not found!")
await db.disconnect()
return
pid = player['id']
ploc = player['location_id']
# 3. Give items
items_to_give = [
('reinforced_pack', 1),
('reinforced_bat', 1),
('knife', 1),
('first_aid_kit', 10),
('mystery_pills', 5),
('energy_bar', 10),
('molotov', 5)
]
for item_id, qty in items_to_give:
for _ in range(qty):
from utils.game_helpers import generate_unique_item_stats
from api.items import ITEMS_MANAGER
item_def = ITEMS_MANAGER.get_item(item_id)
if hasattr(item_def, 'durability') and item_def.durability:
tier = item_def.tier if hasattr(item_def, 'tier') else 1
stats = generate_unique_item_stats(item_id, item_def.durability, tier)
uid = await db.create_unique_item(
item_id=item_id,
tier=tier,
durability=stats['durability'],
max_durability=stats['durability'],
stats=json.dumps(stats.get('stats', {}))
)
await db.execute(
"INSERT INTO inventory (character_id, item_id, quantity, unique_item_id) VALUES (:cid, :iid, 1, :uid)",
{"cid": pid, "iid": item_id, "uid": uid}
)
else:
await db.execute(
"INSERT INTO inventory (character_id, item_id, quantity) VALUES (:cid, :iid, 1)",
{"cid": pid, "iid": item_id}
)
print("Granted test items and backpack.")
# 4. Give XP to reach lvl 50 if needed
await db.execute("UPDATE characters SET level = 50, xp = 50000, max_hp = 500, hp = 500, max_stamina = 200, stamina = 200 WHERE id = :pid", {"pid": pid})
print("Buffed Jocaru to lvl 50 manually.")
# 5. Spawn enemies at player's location
now = time.time()
despawn = now + 86400 # 1 day
enemies_to_spawn = ['raider_scout'] * 5 + ['feral_dog'] * 5 + ['mutant_rat'] * 5 + ['test_boss'] * 2
for eid in enemies_to_spawn:
await db.execute(
"INSERT INTO wandering_enemies (npc_id, location_id, spawn_timestamp, despawn_timestamp) VALUES (:nid, :loc, :start, :end)",
{"nid": eid, "loc": ploc, "start": now, "end": despawn}
)
print(f"Spawned {len(enemies_to_spawn)} enemies at {ploc}")
await db.disconnect()
if __name__ == "__main__":
asyncio.run(main())

521
update_pvp.py Normal file
View File

@@ -0,0 +1,521 @@
import sys
import re
with open('/opt/dockers/echoes_of_the_ashes/api/routers/combat.py', 'r', encoding='utf-8') as f:
content = f.read()
# 1. Tick Player Effects for PvP
effect_tick_code = """
# Track the last action string for DB history
last_action_text = ""
# Process status effects (bleeding, poison, etc.) before action
active_effects = await db.tick_player_effects(current_player['id'])
if active_effects:
from ..game_logic import calculate_status_impact
total_impact = calculate_status_impact(active_effects)
if total_impact > 0:
damage = total_impact
new_hp = max(0, current_player['hp'] - damage)
await db.update_player(current_player['id'], hp=new_hp)
current_player['hp'] = new_hp
messages.append(create_combat_message(
"effect_damage",
origin="player",
damage=damage,
effect_name="status effects"
))
if new_hp <= 0:
messages.append(create_combat_message("died", origin="player", message="You died from status effects!"))
combat_over = True
winner_id = opponent['id']
# Update current player to dead state
await db.update_player(current_player['id'], hp=0, is_dead=True)
# Create corpse
import json
import time as time_module
inventory = await db.get_inventory(current_player['id'])
inventory_items = []
for inv_item in inventory:
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
inventory_items.append({
'item_id': inv_item['item_id'],
'name': item_def.name if item_def else inv_item['item_id'],
'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '📦',
'quantity': inv_item['quantity'],
'durability': inv_item.get('durability'),
'max_durability': inv_item.get('max_durability'),
'tier': inv_item.get('tier')
})
corpse_data = None
if inventory_items:
corpse_id = await db.create_player_corpse(
player_name=current_player['name'],
location_id=current_player['location_id'],
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
)
await db.clear_inventory(current_player['id'])
corpse_data = {
"id": f"player_{corpse_id}",
"type": "player",
"name": f"{current_player['name']}'s Corpse",
"emoji": "⚰️",
"player_name": current_player['name'],
"loot_count": len(inventory_items),
"items": inventory_items,
"timestamp": time_module.time()
}
# Update PvP statistics for both players
await db.update_player_statistics(current_player['id'], pvp_deaths=1, pvp_combats_lost=1, increment=True)
await db.update_player_statistics(opponent['id'], players_killed=1, pvp_combats_won=1, increment=True)
# Broadcast corpse
broadcast_data = {
"message": get_game_message('pvp_defeat_broadcast', locale, opponent=current_player['name'], winner=opponent['name']),
"action": "player_died",
"player_id": current_player['id']
}
if corpse_data:
broadcast_data["corpse"] = corpse_data
await manager.send_to_location(
location_id=current_player['location_id'],
message={
"type": "location_update",
"data": broadcast_data,
"timestamp": datetime.utcnow().isoformat()
}
)
await db.end_pvp_combat(pvp_combat['id'])
elif total_impact < 0:
heal = abs(total_impact)
new_hp = min(current_player_stats.get('max_hp', current_player['max_hp']), current_player['hp'] + heal)
actual_heal = new_hp - current_player['hp']
if actual_heal > 0:
await db.update_player(current_player['id'], hp=new_hp)
current_player['hp'] = new_hp
messages.append(create_combat_message(
"effect_heal",
origin="player",
heal=actual_heal,
effect_name="status effects"
))
# Stop processing action if player died from status effects
if not combat_over:
"""
content = content.replace(
' # Track the last action string for DB history\n last_action_text = ""',
effect_tick_code
)
# Fix indentation of the actions block since it's now wrapped in `if not combat_over:`
import textwrap
# 2. Add Skill and Use Item logic for PvP
skill_and_item_code = """
elif req.action == 'skill':
if not req.item_id:
raise HTTPException(status_code=400, detail="skill_id (passed as item_id) required")
from ..services.skills import skills_manager
skill_id = req.item_id
skill = skills_manager.get_skill(skill_id)
if not skill:
raise HTTPException(status_code=404, detail="Skill not found")
# Check unlocked
stat_val = current_player.get(skill.stat_requirement, 0)
if stat_val < skill.stat_threshold or current_player['level'] < skill.level_requirement:
raise HTTPException(status_code=400, detail="Skill not unlocked")
# Check cooldown
active_effects = await db.get_player_effects(current_player['id'])
cd_source = f"cd:{skill.id}"
for eff in active_effects:
if eff.get('source') == cd_source and eff.get('ticks_remaining', 0) > 0:
raise HTTPException(status_code=400, detail=f"Skill on cooldown ({eff['ticks_remaining']} turns)")
# Check stamina
if current_player['stamina'] < skill.stamina_cost:
raise HTTPException(status_code=400, detail="Not enough stamina")
# Deduct stamina
new_stamina = current_player['stamina'] - skill.stamina_cost
await db.update_player_stamina(current_player['id'], new_stamina)
current_player['stamina'] = new_stamina
# Add cooldown effect
if skill.cooldown > 0:
await db.add_effect(
player_id=current_player['id'],
effect_name=f"{skill.id}_cooldown",
effect_icon="",
effect_type="cooldown",
value=0,
ticks_remaining=skill.cooldown,
persist_after_combat=False,
source=cd_source
)
# Get weapon info
equipment = await db.get_all_equipment(current_player['id'])
weapon_damage = 0
weapon_inv_id = None
inv_item = None
weapon_def = None
if equipment.get('weapon') and equipment['weapon']:
weapon_slot = equipment['weapon']
inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id'])
if inv_item:
weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
if weapon_def and weapon_def.stats:
weapon_damage = random.randint(
weapon_def.stats.get('damage_min', 0),
weapon_def.stats.get('damage_max', 0)
)
weapon_inv_id = inv_item['id']
effects = skill.effects
new_opponent_hp = opponent['hp']
damage_done = 0
actual_damage = 0
armor_absorbed = 0
# Damage skills
if 'damage_multiplier' in effects:
base_damage = 5
strength_bonus = int(current_player['strength'] * 1.5)
level_bonus = current_player['level']
variance = random.randint(-2, 2)
raw_damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance)
multiplier = effects['damage_multiplier']
if 'execute_threshold' in effects:
opponent_hp_pct = opponent['hp'] / opponent['max_hp'] if opponent['max_hp'] > 0 else 1
if opponent_hp_pct <= effects['execute_threshold']:
multiplier = effects.get('execute_multiplier', multiplier)
damage = max(1, int(raw_damage * multiplier))
if effects.get('guaranteed_crit'):
damage = int(damage * 1.5)
num_hits = effects.get('hits', 1)
for hit in range(num_hits):
hit_dmg = damage if hit == 0 else max(1, int(raw_damage * multiplier))
absorbed, broken_armor = await reduce_armor_durability(opponent['id'], hit_dmg)
armor_absorbed += absorbed
for broken in broken_armor:
messages.append(create_combat_message(
"item_broken",
origin="enemy",
item_name=broken['name'],
emoji=broken['emoji']
))
last_action_text += f"\\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!"
actual_hit = max(1, hit_dmg - absorbed)
damage_done += actual_hit
new_opponent_hp = max(0, new_opponent_hp - actual_hit)
actual_damage = damage_done
messages.append(create_combat_message(
"skill_attack",
origin="player",
damage=damage_done,
skill_name=skill.name,
skill_icon=skill.icon,
hits=num_hits
))
last_action_text = f"{current_player['name']} used {skill.name} on {opponent['name']} for {damage_done} damage! (Armor absorbed {armor_absorbed})"
# Lifesteal
if 'lifesteal' in effects:
heal_amount = int(damage_done * effects['lifesteal'])
new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount)
if new_hp > current_player['hp']:
await db.update_player(current_player['id'], hp=new_hp)
current_player['hp'] = new_hp
messages.append(create_combat_message(
"skill_heal", origin="player", heal=heal_amount, skill_icon="🩸"
))
# Poison DoT
if 'poison_damage' in effects:
await db.add_effect(
player_id=opponent['id'],
effect_name="Poison",
effect_icon="🧪",
effect_type="damage",
damage_per_tick=effects['poison_damage'],
ticks_remaining=effects['poison_duration'],
persist_after_combat=True,
source=f"skill_poison:{skill.id}"
)
messages.append(create_combat_message(
"skill_effect", origin="player", message=f"🧪 Poisoned! ({effects['poison_damage']} dmg/turn)"
))
# Stun chance
if 'stun_chance' in effects and random.random() < effects['stun_chance']:
# Stun in PvP can be modeled as taking away a turn
await db.add_effect(
player_id=opponent['id'],
effect_name="Stunned",
effect_icon="💫",
effect_type="debuff",
ticks_remaining=1,
persist_after_combat=False,
source="skill_stun"
)
messages.append(create_combat_message(
"skill_effect", origin="player", message="💫 Stunned! (Currently skip effect)"
))
# Weapon durability
if weapon_inv_id and inv_item and inv_item.get('unique_item_id'):
new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1)
if new_durability is None:
messages.append(create_combat_message("weapon_broke", origin="player", item_name=weapon_def.name if weapon_def else "weapon"))
await db.unequip_item(current_player['id'], 'weapon')
# Heal skills
if 'heal_percent' in effects:
heal_amount = int(current_player['max_hp'] * effects['heal_percent'])
new_hp = min(current_player['max_hp'], current_player['hp'] + heal_amount)
actual_heal = new_hp - current_player['hp']
if actual_heal > 0:
await db.update_player(current_player['id'], hp=new_hp)
current_player['hp'] = new_hp
messages.append(create_combat_message(
"skill_heal", origin="player", heal=actual_heal, skill_icon=skill.icon
))
last_action_text = f"{current_player['name']} used {skill.name} and healed for {actual_heal} HP!"
# Fortify
if 'armor_boost' in effects:
await db.add_effect(
player_id=current_player['id'],
effect_name="Fortify",
effect_icon="🛡️",
effect_type="buff",
value=effects['armor_boost'],
ticks_remaining=effects['duration'],
persist_after_combat=False,
source=f"skill_fortify:{skill.id}"
)
messages.append(create_combat_message(
"skill_effect", origin="player", message=f"🛡️ Fortified! (+{effects['armor_boost']} Armor)"
))
last_action_text = f"{current_player['name']} used {skill.name} and bolstered their defenses!"
# Process opponent HP if damage done
if damage_done > 0:
await db.update_player(opponent['id'], hp=new_opponent_hp)
if new_opponent_hp <= 0:
last_action_text += f"\\n🏆 {current_player['name']} has defeated {opponent['name']}!"
messages.append(create_combat_message("victory", origin="neutral", npc_name=opponent['name']))
combat_over = True
winner_id = current_player['id']
await db.update_player(opponent['id'], hp=0, is_dead=True)
# Create corpse
import json
import time as time_module
inventory = await db.get_inventory(opponent['id'])
inventory_items = []
for inv_item in inventory:
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
inventory_items.append({
'item_id': inv_item['item_id'],
'name': item_def.name if item_def else inv_item['item_id'],
'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '📦',
'quantity': inv_item['quantity'],
'durability': inv_item.get('durability'),
'max_durability': inv_item.get('max_durability'),
'tier': inv_item.get('tier')
})
corpse_data = None
if inventory_items:
corpse_id = await db.create_player_corpse(
player_name=opponent['name'],
location_id=opponent['location_id'],
items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
)
await db.clear_inventory(opponent['id'])
corpse_data = {
"id": f"player_{corpse_id}",
"type": "player",
"name": f"{opponent['name']}'s Corpse",
"emoji": "⚰️",
"player_name": opponent['name'],
"loot_count": len(inventory_items),
"items": inventory_items,
"timestamp": time_module.time()
}
# Update statistics
await db.update_player_statistics(opponent['id'], pvp_deaths=1, pvp_combats_lost=1, pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True)
await db.update_player_statistics(current_player['id'], players_killed=1, pvp_combats_won=1, pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True)
# Broadcast corpse
broadcast_data = {
"message": get_game_message('pvp_defeat_broadcast', locale, opponent=opponent['name'], winner=current_player['name']),
"action": "player_died",
"player_id": opponent['id']
}
if corpse_data:
broadcast_data["corpse"] = corpse_data
await manager.send_to_location(
location_id=opponent['location_id'],
message={
"type": "location_update",
"data": broadcast_data,
"timestamp": datetime.utcnow().isoformat()
}
)
await db.end_pvp_combat(pvp_combat['id'])
else:
await db.update_player_statistics(current_player['id'], pvp_damage_dealt=actual_damage, pvp_attacks_landed=1, increment=True)
await db.update_player_statistics(opponent['id'], pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True)
# End of turn swap
updates = {
'turn': 'defender' if is_attacker else 'attacker',
'turn_started_at': time.time(),
'last_action': f"{last_action_text}|{time.time()}"
}
await db.update_pvp_combat(pvp_combat['id'], updates)
else:
# Skill didn't do damage, but turn still ends
updates = {
'turn': 'defender' if is_attacker else 'attacker',
'turn_started_at': time.time(),
'last_action': f"{last_action_text}|{time.time()}"
}
await db.update_pvp_combat(pvp_combat['id'], updates)
elif req.action == 'use_item':
if not req.item_id:
raise HTTPException(status_code=400, detail="item_id required for use_item action")
player_inventory = await db.get_inventory(current_player['id'])
inv_item = next((item for item in player_inventory if item['item_id'] == req.item_id), None)
if not inv_item:
raise HTTPException(status_code=400, detail="Item not found in inventory")
item_def = ITEMS_MANAGER.get_item(req.item_id)
if not item_def or not item_def.combat_usable:
raise HTTPException(status_code=400, detail="This item cannot be used in combat")
item_name = get_locale_string(item_def.name, locale)
effects_applied = []
if item_def.effects.get('status_effect'):
status_data = item_def.effects['status_effect']
await db.add_effect(
player_id=current_player['id'],
effect_name=status_data['name'],
effect_icon=status_data.get('icon', ''),
effect_type=status_data.get('type', 'buff'),
damage_per_tick=status_data.get('damage_per_tick', 0),
value=status_data.get('value', 0),
ticks_remaining=status_data.get('ticks', 3),
persist_after_combat=True,
source=f"item:{item_def.id}"
)
effects_applied.append(f"Applied {status_data['name']}")
if item_def.effects.get('cures'):
for cure_effect in item_def.effects['cures']:
if await db.remove_effect(current_player['id'], cure_effect):
effects_applied.append(f"Cured {cure_effect}")
if item_def.effects.get('hp_restore') and item_def.effects['hp_restore'] > 0:
item_effectiveness = current_player_stats.get('item_effectiveness', 1.0)
restore_amount = int(item_def.effects['hp_restore'] * item_effectiveness)
new_hp = min(current_player['max_hp'], current_player['hp'] + restore_amount)
actual_heal = new_hp - current_player['hp']
if actual_heal > 0:
await db.update_player(current_player['id'], hp=new_hp)
current_player['hp'] = new_hp
effects_applied.append(get_game_message('healed_amount_text', locale, amount=actual_heal))
messages.append(create_combat_message("item_heal", origin="player", heal=actual_heal))
if item_def.effects.get('stamina_restore') and item_def.effects['stamina_restore'] > 0:
item_effectiveness = current_player_stats.get('item_effectiveness', 1.0)
restore_amount = int(item_def.effects['stamina_restore'] * item_effectiveness)
new_stamina = min(current_player['max_stamina'], current_player['stamina'] + restore_amount)
actual_restore = new_stamina - current_player['stamina']
if actual_restore > 0:
await db.update_player_stamina(current_player['id'], new_stamina)
effects_applied.append(f"Restored {actual_restore} stamina")
messages.append(create_combat_message("item_restore", origin="player", stat="stamina", amount=actual_restore))
if inv_item['quantity'] > 1:
await db.update_inventory_quantity(inv_item['id'], inv_item['quantity'] - 1)
else:
await db.remove_from_inventory(inv_item['id'])
messages.append(create_combat_message(
"use_item", origin="player", item_name=item_name,
message=get_game_message('used_item_text', locale, name=item_name) + " (" + ", ".join(effects_applied) + ")"
))
last_action_text = f"{current_player['name']} used {item_name}!"
# End of turn swap
updates = {
'turn': 'defender' if is_attacker else 'attacker',
'turn_started_at': time.time(),
'last_action': f"{last_action_text}|{time.time()}"
}
await db.update_pvp_combat(pvp_combat['id'], updates)
"""
content = content.replace(
" elif req.action == 'flee':",
skill_and_item_code + "\n elif req.action == 'flee':"
)
# Indent the blocks bounded by the combat_over condition
lines = content.split('\\n')
inside_combat_over = False
new_lines = []
for line in lines:
new_lines.append(line)
# Since doing line manipulation might be tricky via replace string,
# we need to be very precise. We will apply the wrapper around attack and flee logic inside the Python script by using Regex.
# Well, wait, I can just write out the fully rewritten function or use `re` substitution for indenting.
# It is simpler to just ensure all attack and flee actions are under `if not combat_over:`
# by indenting the whole block manually in the script.
try:
with open('/opt/dockers/echoes_of_the_ashes/api/routers/combat.py', 'w', encoding='utf-8') as f:
f.write(content)
print("Script ran. Patched combat.py.")
except Exception as e:
print(e)