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

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

View File

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

53
api/give_gear.py Normal file
View File

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

17
api/give_gear_final.sql Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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