1429 lines
57 KiB
Python
1429 lines
57 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,
|
|
"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 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,
|
|
"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 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')
|
|
})
|
|
|
|
# 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()
|
|
}
|
|
|
|
# Broadcast to location that player died and corpse appeared
|
|
logger.info(f"Broadcasting player_died to location {player['location_id']} for player {player['name']}")
|
|
await manager.send_to_location(
|
|
location_id=player['location_id'],
|
|
message={
|
|
"type": "location_update",
|
|
"data": {
|
|
"message": get_game_message('player_defeated_broadcast', locale, player_name=player['name']),
|
|
"action": "player_died",
|
|
"player_id": player['id'],
|
|
"corpse": corpse_data # Send full corpse 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)
|
|
} |