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