Files
echoes-of-the-ash/api/routers/game_routes.py
2026-02-05 15:00:49 +01:00

1447 lines
58 KiB
Python

"""
Game Routes router.
Auto-generated from main.py migration.
"""
from fastapi import APIRouter, HTTPException, Depends, status, Request
from fastapi.security import HTTPAuthorizationCredentials
from typing import Optional, Dict, Any
from datetime import datetime
import random
import json
import logging
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, get_game_message
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
from ..core.websockets import manager
logger = logging.getLogger(__name__)
# These will be injected by main.py
LOCATIONS = None
ITEMS_MANAGER = None
WORLD = None
redis_manager = None
def init_router_dependencies(locations, items_manager, world, redis_mgr=None):
"""Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
redis_manager = redis_mgr
router = APIRouter(tags=["game"])
@router.get("/api/cache/stats")
async def get_cache_stats(
current_user: dict = Depends(get_current_user)
):
"""Get Redis cache statistics for monitoring performance"""
if not redis_manager or not redis_manager.redis_client:
return {
"enabled": False,
"message": "Redis caching is not available"
}
try:
# Get Redis stats
stats = await redis_manager.get_cache_stats()
# Calculate hit rate
hits = stats.get('keyspace_hits', 0)
misses = stats.get('keyspace_misses', 0)
total_requests = hits + misses
hit_rate = (hits / total_requests * 100) if total_requests > 0 else 0
# Check if current user's inventory is cached
inventory_cached = await redis_manager.get_cached_inventory(current_user['id']) is not None
return {
"enabled": True,
"redis_stats": {
"total_commands_processed": stats.get('total_commands_processed', 0),
"ops_per_second": stats.get('instantaneous_ops_per_sec', 0),
"connected_clients": stats.get('connected_clients', 0),
},
"cache_performance": {
"hits": hits,
"misses": misses,
"total_requests": total_requests,
"hit_rate_percent": round(hit_rate, 2)
},
"current_user": {
"inventory_cached": inventory_cached,
"player_id": current_user['id']
}
}
except Exception as e:
logger.error(f"Error getting cache stats: {e}")
return {
"enabled": True,
"error": str(e)
}
async def _get_enriched_inventory(player_id: int):
"""
Helper function to get enriched inventory data with durability, unique stats, and workbench flags.
Returns: (inventory_list, total_weight, total_volume, max_weight, max_volume)
"""
inventory_raw = await db.get_inventory(player_id)
inventory = []
total_weight = 0.0
total_volume = 0.0
max_weight = 10.0 # Base capacity
max_volume = 10.0 # Base capacity
for inv_item in inventory_raw:
item = ITEMS_MANAGER.get_item(inv_item['item_id'])
if item:
item_weight = item.weight * inv_item['quantity']
# Equipped items count for weight but not volume
if not inv_item['is_equipped']:
item_volume = item.volume * inv_item['quantity']
total_volume += item_volume
total_weight += item_weight
# Get unique item data if this is a unique item
durability = None
max_durability = None
tier = None
unique_stats = None
current_durability = None
needs_repair = False
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')
current_durability = durability
if durability is not None and max_durability is not None:
needs_repair = durability < max_durability
# Check for equipped bags/containers to increase capacity
if inv_item['is_equipped']:
max_weight += unique_stats.get('weight_capacity', 0)
max_volume += unique_stats.get('volume_capacity', 0)
# Workbench flags
is_repairable = getattr(item, 'repairable', False) and inv_item.get('unique_item_id') is not None
is_salvageable = getattr(item, 'uncraftable', False)
inventory.append({
"id": inv_item['id'],
"item_id": item.id,
"name": item.name,
"description": item.description,
"type": item.type,
"category": getattr(item, 'category', item.type),
"quantity": inv_item['quantity'],
"is_equipped": inv_item['is_equipped'],
"equippable": item.equippable,
"consumable": item.consumable,
"weight": item.weight,
"volume": item.volume,
"image_path": item.image_path,
"emoji": item.emoji,
"slot": item.slot,
"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,
"hp_restore": item.effects.get('hp_restore') if item.effects else None,
"stamina_restore": item.effects.get('stamina_restore') if item.effects else None,
"effects": item.effects,
"damage_min": item.stats.get('damage_min') if item.stats else None,
"damage_max": item.stats.get('damage_max') if item.stats else None,
"stats": item.stats,
# Workbench flags
"is_repairable": is_repairable,
"is_salvageable": is_salvageable,
"current_durability": current_durability,
"needs_repair": needs_repair
})
return inventory, total_weight, total_volume, max_weight, max_volume
# Endpoints
@router.get("/api/game/state")
async def get_game_state(current_user: dict = Depends(get_current_user)):
"""Get complete game state for the player"""
player_id = current_user['id']
# Get player data
player = await db.get_player_by_id(player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
# Get player status effects
status_effects = await db.get_player_effects(player_id)
player['status_effects'] = status_effects
# Get location
location = LOCATIONS.get(player['location_id'])
# Get enriched inventory with capacity calculations
inventory, total_weight, total_volume, max_weight, max_volume = await _get_enriched_inventory(player_id)
# Get equipped items
equipment_slots = await db.get_all_equipment(player_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 {}
}
else:
logger.error(f"❌ Item definition not found for equipped item: {inv_item['item_id']} (slot: {slot})")
else:
logger.warning(f"⚠️ Inventory item not found for equipped slot: {slot} (ID: {item_data['item_id']})")
if slot not in equipment:
equipment[slot] = None
# Get combat state
combat = await db.get_active_combat(player_id)
if combat:
# Ensure intent is present (handle legacy)
if 'npc_intent' not in combat or not combat['npc_intent']:
combat['npc_intent'] = 'attack'
# Get dropped items at location and enrich with item data
dropped_items_raw = await db.get_dropped_items(player['location_id'])
dropped_items = []
for dropped_item in dropped_items_raw:
item = ITEMS_MANAGER.get_item(dropped_item['item_id'])
if item:
# Get unique item data if this is a unique item
durability = None
max_durability = None
tier = None
if dropped_item.get('unique_item_id'):
unique_item = await db.get_unique_item(dropped_item['unique_item_id'])
if unique_item:
durability = unique_item.get('durability')
max_durability = unique_item.get('max_durability')
tier = unique_item.get('tier')
dropped_items.append({
"id": dropped_item['id'],
"item_id": item.id,
"name": item.name,
"description": item.description,
"type": item.type,
"quantity": dropped_item['quantity'],
"image_path": item.image_path,
"emoji": item.emoji,
"weight": item.weight,
"volume": item.volume,
"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,
"hp_restore": item.effects.get('hp_restore') if item.effects else None,
"stamina_restore": item.effects.get('stamina_restore') if item.effects else None,
"effects": item.effects,
"damage_min": item.stats.get('damage_min') if item.stats else None,
"damage_max": item.stats.get('damage_max') if item.stats else None
})
# Convert location to dict
location_dict = None
if location:
location_dict = {
"id": location.id,
"name": location.name,
"description": location.description,
"exits": location.exits,
"image_path": location.image_path,
"x": getattr(location, 'x', 0.0),
"y": getattr(location, 'y', 0.0),
"tags": getattr(location, 'tags', [])
}
# 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_volume'] = round(max_volume, 2)
# Calculate movement cooldown
import time
current_time = time.time()
last_movement = player.get('last_movement_time', 0)
time_since_movement = current_time - last_movement
movement_cooldown = max(0, min(5, 5 - time_since_movement))
player_with_capacity['movement_cooldown'] = int(movement_cooldown)
return {
"player": player_with_capacity,
"location": location_dict,
"inventory": inventory,
"equipment": equipment,
"combat": combat,
"dropped_items": dropped_items
}
@router.get("/api/game/profile")
async def get_player_profile(current_user: dict = Depends(get_current_user)):
"""Get player profile information"""
player_id = current_user['id']
player = await db.get_player_by_id(player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
# Get player status effects
status_effects = await db.get_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)
# 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_volume'] = round(max_volume, 2)
# Calculate movement cooldown
import time
current_time = time.time()
last_movement = player.get('last_movement_time', 0)
time_since_movement = current_time - last_movement
movement_cooldown = max(0, min(5, 5 - time_since_movement))
player_with_capacity['movement_cooldown'] = round(movement_cooldown, 1)
return {
"player": player_with_capacity
}
@router.post("/api/game/spend_point")
async def spend_stat_point(
stat: str,
current_user: dict = Depends(get_current_user)
):
"""Spend a stat point on a specific attribute"""
player = current_user # current_user is already the character dict
if not player:
raise HTTPException(status_code=404, detail="Player not found")
if player['unspent_points'] < 1:
raise HTTPException(status_code=400, detail="No unspent points available")
# Valid stats
valid_stats = ['strength', 'agility', 'endurance', 'intellect']
if stat not in valid_stats:
raise HTTPException(status_code=400, detail=f"Invalid stat. Must be one of: {', '.join(valid_stats)}")
# Update the stat and decrease unspent points
update_data = {
stat: player[stat] + 1,
'unspent_points': player['unspent_points'] - 1
}
# Endurance increases max HP
if stat == 'endurance':
update_data['max_hp'] = player['max_hp'] + 5
update_data['hp'] = min(player['hp'] + 5, update_data['max_hp']) # Also heal by 5
await db.update_character(current_user['id'], **update_data)
return {
"success": True,
"message": f"Increased {stat} by 1!",
"new_value": player[stat] + 1,
"remaining_points": player['unspent_points'] - 1
}
@router.get("/api/game/location")
async def get_current_location(request: Request, current_user: dict = Depends(get_current_user)):
"""Get current location information"""
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
location_id = current_user['location_id']
location = LOCATIONS.get(location_id)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Location {location_id} not found"
)
# Get dropped items at location
dropped_items = await db.get_dropped_items(location_id)
# Get wandering enemies at location
wandering_enemies = await db.get_wandering_enemies_in_location(location_id)
# Format interactables for response with cooldown info
interactables_data = []
for interactable in location.interactables:
actions_data = []
for action in interactable.actions:
# Check cooldown status for this specific action
cooldown_expiry = await db.get_interactable_cooldown(interactable.id, action.id)
import time
is_on_cooldown = False
remaining_cooldown = 0
if cooldown_expiry:
current_time = time.time()
if cooldown_expiry > current_time:
is_on_cooldown = True
remaining_cooldown = int(cooldown_expiry - current_time)
actions_data.append({
"id": action.id,
"name": action.label,
"stamina_cost": action.stamina_cost,
"description": f"Costs {action.stamina_cost} stamina",
"on_cooldown": is_on_cooldown,
"cooldown_remaining": remaining_cooldown
})
interactables_data.append({
"instance_id": interactable.id,
"name": interactable.name,
"image_path": interactable.image_path,
"actions": actions_data
})
# Fix image URL - image_path already contains the full path from images/
image_url = f"/{location.image_path}" if location.image_path else "/images/locations/default.webp"
# Calculate player's current weight for stamina cost adjustment
player = current_user # current_user is already the character dict
if not player:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No character selected. Please select a character first."
)
# Use helper function to calculate capacity
inventory = await db.get_inventory(current_user['id'])
total_weight, max_weight, total_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
# Format directions with stamina costs (calculated from distance, weight, agility)
directions_with_stamina = []
player_agility = player.get('agility', 5)
for direction in location.exits.keys():
destination_id = location.exits[direction]
destination_loc = LOCATIONS.get(destination_id)
if destination_loc:
# Calculate real distance using coordinates
distance = calculate_distance(
location.x, location.y,
destination_loc.x, destination_loc.y
)
# Calculate stamina cost based on distance, weight, volume, capacity, and agility
stamina_cost = calculate_stamina_cost(distance, total_weight, player_agility, max_weight, total_volume, max_volume)
destination_name = destination_loc.name
else:
# Fallback if destination not found
distance = 500 # Default 500m
stamina_cost = calculate_stamina_cost(distance, total_weight, player_agility)
destination_name = destination_id
directions_with_stamina.append({
"direction": direction,
"stamina_cost": stamina_cost,
"distance": int(distance), # Round to integer meters
"destination": destination_id,
"destination_name": destination_name
})
# Format NPCs (wandering enemies + static NPCs from JSON)
npcs_data = []
# Add wandering enemies from database
import sys
sys.path.insert(0, '/app')
from data.npcs import NPCS
for enemy in wandering_enemies:
npc_def = NPCS.get(enemy['npc_id'])
npcs_data.append({
"id": enemy['id'],
"npc_id": enemy['npc_id'],
"name": npc_def.name if npc_def else enemy['npc_id'].replace('_', ' ').title(),
"type": "enemy",
"level": enemy.get('level', 1),
"is_wandering": True,
"image_path": npc_def.image_path if npc_def else None
})
# Add static NPCs from location JSON (if any)
for npc in location.npcs:
if isinstance(npc, dict):
npcs_data.append({
"id": npc.get('id', npc.get('name', 'unknown')),
"name": npc.get('name', 'Unknown NPC'),
"type": npc.get('type', 'npc'),
"level": npc.get('level'),
"is_wandering": False
})
else:
npcs_data.append({
"id": npc,
"name": npc,
"type": "npc",
"is_wandering": False
})
# Enrich dropped items with metadata - DON'T consolidate unique items!
items_dict = {}
for item in dropped_items:
item_def = ITEMS_MANAGER.get_item(item['item_id'])
if item_def:
# Get unique item data if this is a unique item
durability = None
max_durability = None
tier = None
if item.get('unique_item_id'):
unique_item = await db.get_unique_item(item['unique_item_id'])
if unique_item:
durability = unique_item.get('durability')
max_durability = unique_item.get('max_durability')
tier = unique_item.get('tier')
# Create a unique key for unique items to prevent stacking
if item.get('unique_item_id'):
dict_key = f"{item['item_id']}_{item['unique_item_id']}"
else:
dict_key = item['item_id']
if dict_key not in items_dict:
items_dict[dict_key] = {
"id": item['id'], # Use first ID for pickup
"item_id": item['item_id'],
"name": item_def.name,
"description": item_def.description,
"quantity": item['quantity'],
"emoji": item_def.emoji,
"image_path": item_def.image_path,
"weight": item_def.weight,
"volume": item_def.volume,
"durability": durability,
"max_durability": max_durability,
"tier": tier,
"hp_restore": item_def.effects.get('hp_restore') if item_def.effects else None,
"stamina_restore": item_def.effects.get('stamina_restore') if item_def.effects else None,
"damage_min": item_def.stats.get('damage_min') if item_def.stats else None,
"damage_max": item_def.stats.get('damage_max') if item_def.stats else None
}
else:
# Only stack if it's not a unique item (stackable items only)
if not item.get('unique_item_id'):
items_dict[dict_key]['quantity'] += item['quantity']
items_data = list(items_dict.values())
# Get other players in the same location (characters from all accounts)
other_players = []
try:
# Use Redis for player registry if available (includes disconnected players)
if redis_manager:
player_ids = await redis_manager.get_players_in_location(location_id)
for pid in player_ids:
if pid == current_user['id']:
continue
# Get player session from Redis
session = await redis_manager.get_player_session(pid)
if session:
# Check if player is connected
is_connected = session.get('websocket_connected') == 'true'
# Check disconnect duration
disconnect_duration = None
if not is_connected:
disconnect_duration = await redis_manager.get_disconnect_duration(pid)
# Get player data from DB for combat checks
char = await db.get_player_by_id(pid)
if not char:
continue
# Don't show dead players
if char.get('is_dead', False):
continue
# Check if character is in any combat (PvE or PvP)
in_pve_combat = await db.get_active_combat(pid)
in_pvp_combat = await db.get_pvp_combat_by_player(pid)
# Don't show characters who are in combat
if in_pve_combat or in_pvp_combat:
continue
# Check if PvP is possible with this character
level_diff = abs(player['level'] - int(session.get('level', 0)))
can_pvp = location.danger_level >= 3 and level_diff <= 3
other_players.append({
"id": pid,
"name": session.get('username'),
"level": int(session.get('level', 0)),
"username": session.get('username'),
"can_pvp": can_pvp,
"level_diff": level_diff,
"is_connected": is_connected,
"vulnerable": not is_connected and location.danger_level >= 3 # Disconnected in dangerous zone
})
else:
# Fallback: Query database directly (single worker mode)
async with db.engine.begin() as conn:
stmt = db.select(db.characters).where(
db.and_(
db.characters.c.location_id == location_id,
db.characters.c.id != current_user['id'],
db.characters.c.is_dead == False # Don't show dead players
)
)
result = await conn.execute(stmt)
characters_rows = result.fetchall()
for char_row in characters_rows:
# Check if character is in any combat (PvE or PvP)
in_pve_combat = await db.get_active_combat(char_row.id)
in_pvp_combat = await db.get_pvp_combat_by_player(char_row.id)
if in_pve_combat or in_pvp_combat:
continue
# Check if PvP is possible with this character
level_diff = abs(player['level'] - char_row.level)
can_pvp = location.danger_level >= 3 and level_diff <= 3
other_players.append({
"id": char_row.id,
"name": char_row.name,
"level": char_row.level,
"username": char_row.name,
"can_pvp": can_pvp,
"level_diff": level_diff,
"is_connected": True, # Assume connected in fallback mode
"vulnerable": False
})
except Exception as e:
print(f"Error fetching other characters: {e}")
# Get corpses at location
npc_corpses = await db.get_npc_corpses_in_location(location_id)
player_corpses = await db.get_player_corpses_in_location(location_id)
# Format corpses for response
corpses_data = []
import json
import sys
sys.path.insert(0, '/app')
from data.npcs import NPCS
for corpse in npc_corpses:
loot = json.loads(corpse['loot_remaining']) if corpse['loot_remaining'] else []
npc_def = NPCS.get(corpse['npc_id'])
corpses_data.append({
"id": f"npc_{corpse['id']}",
"type": "npc",
"name": f"{get_locale_string(npc_def.name, locale) if npc_def else corpse['npc_id']} Corpse",
"emoji": "💀",
"loot_count": len(loot),
"timestamp": corpse['death_timestamp']
})
for corpse in player_corpses:
items = json.loads(corpse['items']) if corpse['items'] else []
corpses_data.append({
"id": f"player_{corpse['id']}",
"type": "player",
"name": f"{corpse['player_name']}'s Corpse",
"emoji": "⚰️",
"loot_count": len(items),
"timestamp": corpse['death_timestamp']
})
return {
"id": location.id,
"name": location.name,
"description": location.description,
"image_url": image_url,
"directions": list(location.exits.keys()), # Keep for backwards compatibility
"directions_detailed": directions_with_stamina, # New detailed format
"danger_level": location.danger_level,
"tags": location.tags if hasattr(location, 'tags') else [], # Include location tags
"npcs": npcs_data,
"items": items_data,
"interactables": interactables_data,
"other_players": other_players,
"corpses": corpses_data
}
@router.post("/api/game/move")
async def move(
move_req: MoveRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Move player in a direction"""
import time
# Check if player is in PvP combat and hasn't acknowledged
pvp_combat = await db.get_pvp_combat_by_player(current_user['id'])
if pvp_combat:
is_attacker = pvp_combat['attacker_character_id'] == current_user['id']
acknowledged = pvp_combat.get('attacker_acknowledged', False) if is_attacker else pvp_combat.get('defender_acknowledged', False)
# Check if combat ended - need to get actual player HP
attacker = await db.get_player_by_id(pvp_combat['attacker_character_id'])
defender = await db.get_player_by_id(pvp_combat['defender_character_id'])
# Only block if combat is still active (not fled, not defeated) and player hasn't acknowledged
combat_ended = pvp_combat.get('attacker_fled', False) or pvp_combat.get('defender_fled', False) or \
attacker['hp'] <= 0 or defender['hp'] <= 0
if not acknowledged and not combat_ended:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot move while in PvP combat!"
)
# Check movement cooldown (5 seconds)
player = current_user # current_user is already the character dict
current_time = time.time()
last_movement = player.get('last_movement_time', 0)
cooldown_remaining = max(0, 5 - (current_time - last_movement))
if cooldown_remaining > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=get_game_message('move_cooldown', locale, seconds=int(cooldown_remaining))
)
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
success, message, new_location_id, stamina_cost, distance = await game_logic.move_player(
current_user['id'],
move_req.direction,
LOCATIONS,
locale
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message
)
# Update last movement time
await db.update_player(current_user['id'], last_movement_time=current_time)
# Update Redis cache: Move player between locations
if redis_manager:
await redis_manager.move_player_between_locations(
current_user['id'],
player['location_id'],
new_location_id
)
# Update player session with new location
await redis_manager.update_player_session_field(current_user['id'], 'location_id', new_location_id)
await redis_manager.update_player_session_field(current_user['id'], 'stamina', player['stamina'] - stamina_cost)
# Track movement statistics - use actual distance in meters
await db.update_player_statistics(current_user['id'], distance_walked=distance, increment=True)
# Check for encounter upon arrival (if danger level > 1)
import random
import sys
sys.path.insert(0, '/app')
from data.npcs import get_random_npc_for_location, LOCATION_DANGER, NPCS
new_location = LOCATIONS.get(new_location_id)
encounter_triggered = False
enemy_id = None
combat_data = None
if new_location and new_location.danger_level > 1:
# Get encounter rate from danger config
danger_data = LOCATION_DANGER.get(new_location_id)
if danger_data:
_, encounter_rate, _ = danger_data
# Roll for encounter
if random.random() < encounter_rate:
# Get a random enemy for this location
enemy_id = get_random_npc_for_location(new_location_id)
if enemy_id:
# Check if player is already in combat
existing_combat = await db.get_active_combat(current_user['id'])
if not existing_combat:
# Get NPC definition
npc_def = NPCS.get(enemy_id)
if npc_def:
# Randomize HP
npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max)
# Create combat directly
# Generate initial intent
# Generate initial intent
# game_logic is already imported at module level
npc_def = NPCS.get(enemy_id)
initial_intent = game_logic.generate_npc_intent(npc_def, {
'npc_hp': npc_hp,
'npc_max_hp': npc_hp
})
combat = await db.create_combat(
player_id=current_user['id'],
npc_id=enemy_id,
npc_hp=npc_hp,
npc_max_hp=npc_hp,
location_id=new_location_id,
from_wandering=False, # This is an encounter, not wandering
npc_intent=initial_intent['type']
)
# Track combat initiation
await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True)
encounter_triggered = True
combat_data = {
"npc_id": enemy_id,
"npc_name": npc_def.name,
"npc_hp": npc_hp,
"npc_max_hp": npc_hp,
"npc_image": npc_def.image_path,
"turn": "player",
"round": 1,
"npc_intent": initial_intent['type']
}
response = {
"success": True,
"message": message,
"new_location_id": new_location_id
}
# Add encounter info if triggered
if encounter_triggered:
response["encounter"] = {
"triggered": True,
"enemy_id": enemy_id,
"message": get_game_message('enemy_ambush', locale),
"combat": combat_data
}
# Broadcast movement to WebSocket clients
# Notify old location that player left
await manager.send_to_location(
player['location_id'],
{
"type": "location_update",
"data": {
"message": get_game_message('player_left', locale, player_name=player['name']),
"action": "player_left",
"player_id": current_user['id'],
"player_name": player['name']
},
"timestamp": datetime.utcnow().isoformat()
},
exclude_player_id=current_user['id']
)
# Notify new location that player arrived
await manager.send_to_location(
new_location_id,
{
"type": "location_update",
"data": {
"message": get_game_message('player_arrived', locale, player_name=player['name']),
"action": "player_arrived",
"player_id": current_user['id'],
"player_name": player['name'],
"player_level": player['level'],
"can_pvp": new_location.danger_level >= 3 # Full player data for UI update
},
"timestamp": datetime.utcnow().isoformat()
},
exclude_player_id=current_user['id']
)
# Send state update to the moving player
await manager.send_personal_message(current_user['id'], {
"type": "state_update",
"data": {
"player": {
"stamina": player['stamina'] - stamina_cost,
"location_id": new_location_id
},
"location": {
"id": new_location.id,
"name": new_location.name
} if new_location else None,
"encounter": response.get("encounter")
},
"timestamp": datetime.utcnow().isoformat()
})
return response
@router.post("/api/game/inspect")
async def inspect(request: Request, current_user: dict = Depends(get_current_user)):
"""Inspect the current area"""
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
location_id = current_user['location_id']
location = LOCATIONS.get(location_id)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found"
)
# Get dropped items
dropped_items = await db.get_dropped_items(location_id)
message = await game_logic.inspect_area(
current_user['id'],
location,
{}, # interactables_data - not needed with new structure
locale
)
return {
"success": True,
"message": message
}
@router.post("/api/game/interact")
async def interact(
interact_req: InteractRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Interact with an object in the game world"""
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Check if player is in combat
combat = await db.get_active_combat(current_user['id'])
if combat:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=get_game_message('interact_in_combat', locale)
)
location_id = current_user['location_id']
location = LOCATIONS.get(location_id)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found"
)
result = await game_logic.interact_with_object(
current_user['id'],
interact_req.interactable_id,
interact_req.action_id,
location,
ITEMS_MANAGER,
locale
)
if not result['success']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result['message']
)
# Broadcast interactable cooldown to all players in location
from datetime import datetime
# Find the interactable name and action name
interactable = None
action_name = None
for obj in location.interactables:
if obj.id == interact_req.interactable_id:
interactable = obj
for act in obj.actions:
if act.id == interact_req.action_id:
action_name = act.label
break
break
interactable_name = interactable.name if interactable else "Object"
action_display = action_name if action_name else interact_req.action_id
# Get the actual cooldown expiry from database and calculate remaining time
cooldown_expiry = await db.get_interactable_cooldown(
interact_req.interactable_id,
interact_req.action_id
)
# Calculate remaining cooldown in seconds
import time as time_module
current_time = time_module.time()
cooldown_remaining = 0
if cooldown_expiry and cooldown_expiry > current_time:
cooldown_remaining = int(cooldown_expiry - current_time)
# Only broadcast if there are players in the location
if manager.has_players_in_location(location_id):
await manager.send_to_location(
location_id=location_id,
message={
"type": "interactable_cooldown",
"data": {
"instance_id": interact_req.interactable_id,
"action_id": interact_req.action_id,
"cooldown_remaining": cooldown_remaining,
"message": f"{current_user['name']} used {get_locale_string(action_display, locale)} on {get_locale_string(interactable_name, locale)}"
},
"timestamp": datetime.utcnow().isoformat()
}
)
return result
@router.post("/api/game/use_item")
async def use_item(
use_req: UseItemRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Use an item from inventory"""
import random
import sys
sys.path.insert(0, '/app')
from data.npcs import NPCS
# Check if in combat
combat = await db.get_active_combat(current_user['id'])
in_combat = combat is not None
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
result = await game_logic.use_item(
current_user['id'],
use_req.item_id,
ITEMS_MANAGER,
locale
)
if not result['success']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result['message']
)
# If in combat, enemy gets a turn
if in_combat and combat['turn'] == 'player':
player = current_user # current_user is already the character dict
npc_def = NPCS.get(combat['npc_id'])
# Enemy attacks
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
if combat['npc_hp'] / combat['npc_max_hp'] < 0.3:
npc_damage = int(npc_damage * 1.5)
new_player_hp = max(0, player['hp'] - npc_damage)
combat_message = get_game_message('combat_enemy_attack', locale, name=npc_def.name, damage=npc_damage)
if new_player_hp <= 0:
combat_message += get_game_message('combat_defeated', locale)
await db.update_player(current_user['id'], hp=0, is_dead=True)
await db.end_combat(current_user['id'])
result['combat_over'] = True
result['player_won'] = False
# Create corpse with player's inventory
import json
import time as time_module
try:
inventory = await db.get_inventory(current_user['id'])
inventory_items = []
for inv_item in inventory:
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
inventory_items.append({
'item_id': inv_item['item_id'],
'name': item_def.name if item_def else inv_item['item_id'],
'emoji': item_def.emoji if (item_def and hasattr(item_def, 'emoji')) else '📦',
'quantity': inv_item['quantity'],
'durability': inv_item.get('durability'),
'max_durability': inv_item.get('max_durability'),
'tier': inv_item.get('tier')
})
# Only create corpse if player has items
corpse_data = None
if inventory_items:
# Store minimal data in database
db_items = json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory])
logger.info(f"Creating player corpse for {player['name']} at {player['location_id']} with {len(inventory_items)} items")
corpse_id = await db.create_player_corpse(
player_name=player['name'],
location_id=player['location_id'],
items=db_items
)
logger.info(f"Successfully created player corpse: ID={corpse_id}, player={player['name']}, location={player['location_id']}, items_count={len(inventory_items)}")
# Clear player's inventory (items are now in corpse)
await db.clear_inventory(current_user['id'])
# Build corpse data for broadcast
corpse_data = {
"id": f"player_{corpse_id}",
"type": "player",
"name": f"{player['name']}'s Corpse",
"emoji": "⚰️",
"player_name": player['name'],
"loot_count": len(inventory_items),
"items": inventory_items, # Full item list for UI
"timestamp": time_module.time()
}
else:
logger.info(f"Player {player['name']} died (use_item combat) with no items, skipping corpse creation")
# Broadcast to location that player died (and corpse if created)
logger.info(f"Broadcasting player_died to location {player['location_id']} for player {player['name']}")
broadcast_data = {
"message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']),
"action": "player_died",
"player_id": player['id']
}
if corpse_data:
broadcast_data["corpse"] = corpse_data
await manager.send_to_location(
location_id=player['location_id'],
message={
"type": "location_update",
"data": broadcast_data,
"timestamp": datetime.utcnow().isoformat()
},
exclude_player_id=current_user['id']
)
except Exception as e:
logger.error(f"Error creating player corpse for {player['name']}: {e}", exc_info=True)
else:
await db.update_player(current_user['id'], hp=new_player_hp)
result['message'] += combat_message
result['in_combat'] = True
result['combat_over'] = result.get('combat_over', False)
return result
@router.post("/api/game/pickup")
async def pickup(
pickup_req: PickupItemRequest,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Pick up an item from the ground"""
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Get item details for broadcast BEFORE picking it up (it will be removed from DB)
# pickup_req.item_id is the dropped_item database ID, not the item_id string
dropped_item = await db.get_dropped_item(pickup_req.item_id)
if dropped_item:
item_def = ITEMS_MANAGER.get_item(dropped_item['item_id'])
item_name = get_locale_string(item_def.name, locale) if item_def else dropped_item['item_id']
else:
item_name = "item"
result = await game_logic.pickup_item(
current_user['id'],
pickup_req.item_id,
current_user['location_id'],
pickup_req.quantity,
ITEMS_MANAGER,
locale
)
if not result['success']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result['message']
)
# Track pickup statistics
quantity = pickup_req.quantity if pickup_req.quantity else 1
await db.update_player_statistics(current_user['id'], items_collected=quantity, increment=True)
# Broadcast pickup to other players in location
player = current_user # current_user is already the character dict
await manager.send_to_location(
player['location_id'],
{
"type": "location_update",
"data": {
"message": f"{player['name']} {get_game_message('picked_up', locale).lower()} {quantity}x {item_name}",
"action": "item_picked_up"
},
"timestamp": datetime.utcnow().isoformat()
},
exclude_player_id=current_user['id']
)
# Send state update to the player
await manager.send_personal_message(current_user['id'], {
"type": "inventory_update",
"timestamp": datetime.utcnow().isoformat()
})
return result
@router.get("/api/game/inventory")
async def get_inventory(current_user: dict = Depends(get_current_user)):
"""Get player inventory"""
inventory = await db.get_inventory(current_user['id'])
# Enrich with item data
inventory_items = []
for inv_item in inventory:
item = ITEMS_MANAGER.get_item(inv_item['item_id'])
if item:
item_data = {
"id": inv_item['id'],
"item_id": item.id,
"name": item.name,
"description": item.description,
"type": item.type,
"quantity": inv_item['quantity'],
"is_equipped": inv_item['is_equipped'],
"equippable": item.equippable,
"consumable": item.consumable,
"image_path": item.image_path,
"emoji": item.emoji if hasattr(item, 'emoji') else None,
"weight": item.weight if hasattr(item, 'weight') else 0,
"volume": item.volume if hasattr(item, 'volume') else 0,
"uncraftable": getattr(item, 'uncraftable', False),
"inventory_id": inv_item['id'],
"unique_item_id": inv_item.get('unique_item_id')
}
# Add combat/consumable stats if they exist
if hasattr(item, 'hp_restore'):
item_data["hp_restore"] = item.hp_restore
if hasattr(item, 'stamina_restore'):
item_data["stamina_restore"] = item.stamina_restore
if hasattr(item, 'damage_min'):
item_data["damage_min"] = item.damage_min
if hasattr(item, 'damage_max'):
item_data["damage_max"] = item.damage_max
# Add tier if unique item
if inv_item.get('unique_item_id'):
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
if unique_item:
item_data["tier"] = unique_item.get('tier', 1)
item_data["durability"] = unique_item.get('durability', 0)
item_data["max_durability"] = unique_item.get('max_durability', 100)
# Add uncraft data if uncraftable
if getattr(item, 'uncraftable', False):
uncraft_yield = getattr(item, 'uncraft_yield', [])
uncraft_tools = getattr(item, 'uncraft_tools', [])
# Format materials
yield_materials = []
for mat in uncraft_yield:
mat_def = ITEMS_MANAGER.get_item(mat['item_id'])
yield_materials.append({
'item_id': mat['item_id'],
'name': mat_def.name if mat_def else mat['item_id'],
'emoji': mat_def.emoji if mat_def else '📦',
'quantity': mat['quantity']
})
# Check tools availability
tools_info = []
can_uncraft = True
for tool_req in uncraft_tools:
tool_id = tool_req['item_id']
durability_cost = tool_req['durability_cost']
tool_def = ITEMS_MANAGER.get_item(tool_id)
# Check if player has this tool
tool_found = False
tool_durability = 0
for check_item in inventory:
if check_item['item_id'] == tool_id and check_item.get('unique_item_id'):
unique = await db.get_unique_item(check_item['unique_item_id'])
if unique and unique.get('durability', 0) >= durability_cost:
tool_found = True
tool_durability = unique.get('durability', 0)
break
tools_info.append({
'item_id': tool_id,
'name': tool_def.name if tool_def else tool_id,
'emoji': tool_def.emoji if tool_def else '🔧',
'durability_cost': durability_cost,
'has_tool': tool_found,
'tool_durability': tool_durability
})
if not tool_found:
can_uncraft = False
item_data["uncraft_yield"] = yield_materials
item_data["uncraft_loss_chance"] = getattr(item, 'uncraft_loss_chance', 0.3)
item_data["uncraft_tools"] = tools_info
item_data["can_uncraft"] = can_uncraft
inventory_items.append(item_data)
return {"items": inventory_items}
@router.post("/api/game/item/drop")
async def drop_item(
drop_req: dict,
request: Request,
current_user: dict = Depends(get_current_user)
):
"""Drop an item from inventory"""
player_id = current_user['id']
item_id = drop_req.get('item_id') # This is the item_id string like "energy_bar"
quantity = drop_req.get('quantity', 1)
# Extract locale from Accept-Language header
locale = request.headers.get('Accept-Language', 'en')
# Get player to know their location
player = await db.get_player_by_id(player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
# Get inventory item by item_id (string), not database id
inventory = await db.get_inventory(player_id)
inv_item = None
for item in inventory:
if item['item_id'] == item_id:
inv_item = item
break
if not inv_item:
raise HTTPException(status_code=404, detail="Item not found in inventory")
if inv_item['quantity'] < quantity:
raise HTTPException(status_code=400, detail="Not enough items to drop")
# For unique items, we need to handle each one individually
if inv_item.get('unique_item_id'):
# This is a unique item - drop it and remove from inventory by row ID
await db.add_dropped_item(
player['location_id'],
inv_item['item_id'],
1,
unique_item_id=inv_item['unique_item_id']
)
# Remove this specific inventory row (not by item_id, by row id)
await db.remove_inventory_row(inv_item['id'])
else:
# Stackable item - drop the quantity requested
await db.add_dropped_item(
player['location_id'],
inv_item['item_id'],
quantity,
unique_item_id=None
)
# Remove from inventory (handles quantity reduction automatically)
await db.remove_item_from_inventory(player_id, inv_item['item_id'], quantity)
# Track drop statistics
await db.update_player_statistics(player_id, items_dropped=quantity, increment=True)
# Invalidate inventory cache
if redis_manager:
await redis_manager.invalidate_inventory(player_id)
# Get item details for broadcast
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
# Broadcast to location that item was dropped
await manager.send_to_location(
location_id=player['location_id'],
message={
"type": "location_update",
"data": {
"message": get_game_message('dropped_item_success', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=quantity).replace('You', player['name']).replace('Has tirado', f"{player['name']} ha tirado"),
"action": "item_dropped"
},
"timestamp": datetime.utcnow().isoformat()
},
exclude_player_id=player_id
)
return {
"success": True,
"message": get_game_message('dropped_item_success', locale, emoji=item_def.emoji, name=get_locale_string(item_def.name, locale), qty=quantity)
}