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

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