""" 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 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=f"You must wait {int(cooldown_remaining)} seconds before moving again." ) # 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": f"⚠️ An enemy ambushes you upon arrival!", "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": f"{player['name']} left the area", "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": f"{player['name']} arrived", "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(current_user: dict = Depends(get_current_user)): """Inspect the current area""" 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 ) 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="Cannot interact with objects while in combat" ) 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 ) 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, 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 result = await game_logic.use_item( current_user['id'], use_req.item_id, ITEMS_MANAGER ) 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 = f"\n{npc_def.name} attacks for {npc_damage} damage!" if new_player_hp <= 0: combat_message += "\nYou have been defeated!" 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": f"{player['name']} was defeated in combat", "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 ) 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']} picked up {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, 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) # 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": f"{player['name']} dropped {item_def.emoji} {item_def.name} x{quantity}", "action": "item_dropped" }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player_id ) return { "success": True, "message": f"Dropped {item_def.emoji} {get_locale_string(item_def.name)} x{quantity}" }