""" Standalone FastAPI application for Echoes of the Ashes. All dependencies are self-contained in the api/ directory. """ from fastapi import FastAPI, HTTPException, Depends, status, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from typing import Optional, List, Dict, Any import jwt import bcrypt import asyncio from datetime import datetime, timedelta import os import math import time from contextlib import asynccontextmanager from pathlib import Path import json import logging import traceback # Import our standalone modules from . import database as db from .world_loader import load_world, World, Location from .items import ItemsManager from . import game_logic from . import background_tasks from .redis_manager import redis_manager # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # Helper function for distance calculation def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float: """ Calculate distance between two points using Euclidean distance. Coordinate system: 1 unit = 100 meters (so distance(0,0 to 1,1) = 141.4m) """ # Calculate distance in coordinate units coord_distance = math.sqrt((x2 - x1)**2 + (y2 - y1)**2) # Convert to meters (1 coordinate unit = 100 meters) distance_meters = coord_distance * 100 return distance_meters def calculate_stamina_cost(distance: float, weight: float, agility: int, max_weight: float = 10.0, volume: float = 0.0, max_volume: float = 10.0) -> int: """ Calculate stamina cost based on distance, weight, volume, capacity, and agility. - Base cost: distance / 50 (so 50m = 1 stamina, 100m = 2 stamina) - Weight penalty: +1 stamina per 10kg - Agility reduction: -1 stamina per 3 agility points - Over-capacity penalty: 50-200% extra if over weight OR volume limits - Minimum: 1 stamina """ base_cost = max(1, round(distance / 50)) weight_penalty = int(weight / 10) agility_reduction = int(agility / 3) # Add over-capacity penalty (50% extra stamina cost if over limit) over_capacity_penalty = 0 if weight > max_weight or volume > max_volume: weight_excess_ratio = max(0, (weight - max_weight) / max_weight) if max_weight > 0 else 0 volume_excess_ratio = max(0, (volume - max_volume) / max_volume) if max_volume > 0 else 0 excess_ratio = max(weight_excess_ratio, volume_excess_ratio) # Penalty scales from 50% to 200% based on how much over capacity over_capacity_penalty = int((base_cost + weight_penalty) * (0.5 + min(1.5, excess_ratio))) total_cost = max(1, base_cost + weight_penalty + over_capacity_penalty - agility_reduction) return total_cost async def calculate_player_capacity(player_id: int): """ Calculate player's current and max weight/volume capacity. Returns: (current_weight, max_weight, current_volume, max_volume) """ inventory = await db.get_inventory(player_id) current_weight = 0.0 current_volume = 0.0 max_weight = 10.0 # Base capacity max_volume = 10.0 # Base capacity for inv_item in inventory: item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) if item_def: current_weight += item_def.weight * inv_item['quantity'] current_volume += item_def.volume * inv_item['quantity'] # Check for equipped bags/containers that increase capacity if inv_item['is_equipped'] and item_def.stats: max_weight += item_def.stats.get('weight_capacity', 0) max_volume += item_def.stats.get('volume_capacity', 0) return current_weight, max_weight, current_volume, max_volume # Lifespan context manager for startup/shutdown @asynccontextmanager async def lifespan(app: FastAPI): # Startup await db.init_db() print("✅ Database initialized") # Connect to Redis await redis_manager.connect() print("✅ Redis connected") # Inject Redis manager into ConnectionManager manager.set_redis_manager(redis_manager) # Subscribe to all location channels + global broadcast location_channels = [f"location:{loc_id}" for loc_id in LOCATIONS.keys()] await redis_manager.subscribe_to_channels(location_channels + ['game:broadcast']) print(f"✅ Subscribed to {len(location_channels)} location channels") # Register this worker await redis_manager.register_worker() print(f"✅ Worker registered: {redis_manager.worker_id}") # Start Redis message listener (background task) redis_manager.start_listener(manager.handle_redis_message) print("✅ Redis listener started") # Start background tasks (distributed via Redis locks) tasks = await background_tasks.start_background_tasks(manager, LOCATIONS) if tasks: print(f"✅ Started {len(tasks)} background tasks in this worker") else: print("⏭️ Background tasks running in another worker") yield # Shutdown await background_tasks.stop_background_tasks(tasks) # Unregister worker await redis_manager.unregister_worker() print(f"🔌 Worker unregistered: {redis_manager.worker_id}") # Disconnect from Redis await redis_manager.disconnect() print("✅ Redis disconnected") app = FastAPI( title="Echoes of the Ash API", version="2.0.0", description="Standalone game API with web and bot support", lifespan=lifespan ) # CORS configuration app.add_middleware( CORSMiddleware, allow_origins=[ "https://echoesoftheashgame.patacuack.net", "http://localhost:3000", "http://localhost:5173" ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Mount static files for images images_dir = Path(__file__).parent.parent / "images" if images_dir.exists(): app.mount("/images", StaticFiles(directory=str(images_dir)), name="images") print(f"✅ Mounted images directory: {images_dir}") else: print(f"⚠️ Images directory not found: {images_dir}") # JWT Configuration SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production-please") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days # Internal API key for bot communication API_INTERNAL_KEY = os.getenv("API_INTERNAL_KEY", "change-this-internal-key") security = HTTPBearer() oauth2_scheme = security # Alias for token extraction in character endpoints # Load game data print("🔄 Loading game world...") WORLD: World = load_world() LOCATIONS: Dict[str, Location] = WORLD.locations ITEMS_MANAGER = ItemsManager() print(f"✅ Game world ready: {len(LOCATIONS)} locations, {len(ITEMS_MANAGER.items)} items") # ============================================================================ # WebSocket Connection Manager # ============================================================================ class ConnectionManager: """ Manages WebSocket connections for real-time game updates. Tracks active connections and provides methods for broadcasting messages. Now uses Redis pub/sub for cross-worker communication. """ def __init__(self): # Maps player_id -> WebSocket connection (local to this worker only) self.active_connections: Dict[int, WebSocket] = {} # Maps player_id -> username for debugging self.player_usernames: Dict[int, str] = {} # Redis manager instance (injected later) self.redis_manager = None def set_redis_manager(self, redis_manager): """Inject Redis manager after initialization.""" self.redis_manager = redis_manager async def connect(self, websocket: WebSocket, player_id: int, username: str): """Accept a new WebSocket connection and track it.""" await websocket.accept() self.active_connections[player_id] = websocket self.player_usernames[player_id] = username # Subscribe to player's personal channel if self.redis_manager: await self.redis_manager.subscribe_to_channels([f"player:{player_id}"]) await self.redis_manager.mark_player_connected(player_id) print(f"🔌 WebSocket connected: {username} (player_id={player_id}, worker={self.redis_manager.worker_id if self.redis_manager else 'N/A'})") async def disconnect(self, player_id: int): """Remove a WebSocket connection.""" if player_id in self.active_connections: username = self.player_usernames.get(player_id, "unknown") del self.active_connections[player_id] if player_id in self.player_usernames: del self.player_usernames[player_id] # Unsubscribe from player's personal channel if self.redis_manager: await self.redis_manager.unsubscribe_from_channel(f"player:{player_id}") await self.redis_manager.mark_player_disconnected(player_id) print(f"🔌 WebSocket disconnected: {username} (player_id={player_id})") async def send_personal_message(self, player_id: int, message: dict): """Send a message to a specific player via Redis pub/sub.""" if self.redis_manager: # Send locally first if player is connected to this worker if player_id in self.active_connections: await self._send_direct(player_id, message) else: # Publish to Redis (player might be on another worker) await self.redis_manager.publish_to_player(player_id, message) else: # Fallback to direct send (single worker mode) await self._send_direct(player_id, message) async def _send_direct(self, player_id: int, message: dict): """Directly send to local WebSocket connection.""" if player_id in self.active_connections: try: print(f"📨 Sending {message.get('type')} to player {player_id}") await self.active_connections[player_id].send_json(message) except Exception as e: print(f"❌ Failed to send message to player {player_id}: {e}") await self.disconnect(player_id) else: print(f"⚠️ Player {player_id} not in active connections, cannot send {message.get('type')}") async def broadcast(self, message: dict, exclude_player_id: Optional[int] = None): """Broadcast a message to all connected players via Redis.""" if self.redis_manager: await self.redis_manager.publish_global_broadcast(message) # ALSO send to LOCAL connections immediately for player_id in list(self.active_connections.keys()): if player_id != exclude_player_id: await self._send_direct(player_id, message) else: # Fallback: direct broadcast to local connections disconnected = [] for player_id, connection in self.active_connections.items(): if player_id != exclude_player_id: try: await connection.send_json(message) except Exception as e: print(f"❌ Failed to broadcast to player {player_id}: {e}") disconnected.append(player_id) for player_id in disconnected: await self.disconnect(player_id) async def send_to_location(self, location_id: str, message: dict, exclude_player_id: Optional[int] = None): """Send a message to all players in a specific location via Redis pub/sub.""" if self.redis_manager: # Use Redis pub/sub for cross-worker broadcast message_with_exclude = { **message, "exclude_player_id": exclude_player_id } await self.redis_manager.publish_to_location(location_id, message_with_exclude) # ALSO send to LOCAL connections immediately (don't wait for Redis roundtrip) player_ids = await self.redis_manager.get_players_in_location(location_id) for player_id in player_ids: if player_id == exclude_player_id: continue if player_id in self.active_connections: await self._send_direct(player_id, message) else: # Fallback: Query DB and send directly (single worker mode) players_in_location = await db.get_players_in_location(location_id) active_players = [p for p in players_in_location if p['id'] in self.active_connections and p['id'] != exclude_player_id] if not active_players: return print(f"📍 Broadcasting to location {location_id}: {message.get('type')} (excluding player {exclude_player_id})") disconnected = [] sent_count = 0 for player in active_players: player_id = player['id'] try: await self.active_connections[player_id].send_json(message) sent_count += 1 except Exception as e: print(f"❌ Failed to send to player {player_id}: {e}") disconnected.append(player_id) print(f" 📤 Sent {message.get('type')} to {sent_count} players") for player_id in disconnected: await self.disconnect(player_id) async def handle_redis_message(self, channel: str, data: dict): """Handle incoming Redis pub/sub messages and route to local WebSocket connections. This method is called by RedisManager when a message arrives on a subscribed channel. Only sends to WebSocket connections that are local to this worker. """ try: # Extract message type and data message = { "type": data.get("type"), "data": data.get("data") } # Determine routing based on channel type if channel.startswith("player:"): # Personal message to specific player player_id = int(channel.split(":")[1]) if player_id in self.active_connections: await self._send_direct(player_id, message) elif channel.startswith("location:"): # Broadcast to all players in location (only local connections) location_id = channel.split(":")[1] exclude_player_id = data.get("exclude_player_id") # Get players from Redis location registry if self.redis_manager: player_ids = await self.redis_manager.get_players_in_location(location_id) for player_id in player_ids: if player_id == exclude_player_id: continue # Only send if this worker has the connection if player_id in self.active_connections: await self._send_direct(player_id, message) elif channel == "game:broadcast": # Global broadcast to all local connections exclude_player_id = data.get("exclude_player_id") for player_id in list(self.active_connections.keys()): if player_id != exclude_player_id: await self._send_direct(player_id, message) except Exception as e: print(f"❌ Error handling Redis message on channel {channel}: {e}") def has_players_in_location(self, location_id: str) -> bool: """Check if there are any players with active connections in a specific location (synchronous check).""" return len(self.active_connections) > 0 def get_connected_count(self) -> int: """Get the number of active WebSocket connections.""" return len(self.active_connections) # Global connection manager instance manager = ConnectionManager() # ============================================================================ # Pydantic Models # ============================================================================ class UserRegister(BaseModel): email: str password: str class UserLogin(BaseModel): email: str password: str class CharacterCreate(BaseModel): name: str strength: int = 0 agility: int = 0 endurance: int = 0 intellect: int = 0 avatar_data: Optional[str] = None class CharacterSelect(BaseModel): character_id: int class MoveRequest(BaseModel): direction: str class InteractRequest(BaseModel): interactable_id: str action_id: str class UseItemRequest(BaseModel): item_id: str class PickupItemRequest(BaseModel): item_id: int # This is the dropped_item database ID, not the item type string quantity: int = 1 # How many to pick up (default: 1) class InitiateCombatRequest(BaseModel): enemy_id: int # wandering_enemies.id from database class CombatActionRequest(BaseModel): action: str # 'attack', 'defend', 'flee' # ============================================================================ # JWT Helper Functions # ============================================================================ def create_access_token(data: dict) -> str: """Create a JWT access token""" to_encode = data.copy() expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: """Verify JWT token and return current character (requires character selection)""" try: token = credentials.credentials payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) # New system: account_id + character_id character_id = payload.get("character_id") account_id = payload.get("account_id") # Check if this is a new token format if account_id is not None: if character_id is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="No character selected. Please select a character first." ) character = await db.get_character_by_id(character_id) if character is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Character not found" ) # Verify character belongs to account if character["account_id"] != account_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Character does not belong to this account" ) return character # Old system fallback: player_id (for backward compatibility during migration) player_id = payload.get("player_id") if player_id is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication credentials" ) player = await db.get_player_by_id(player_id) if player is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Player not found" ) return player except jwt.ExpiredSignatureError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired" ) except (jwt.InvalidTokenError, jwt.DecodeError, Exception): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" ) def decode_token(token: str) -> dict: """Decode JWT token and return payload""" try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return payload except jwt.ExpiredSignatureError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired" ) except (jwt.InvalidTokenError, jwt.DecodeError, Exception): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials" ) # ============================================================================ # Authentication Endpoints # ============================================================================ @app.post("/api/auth/register") async def register(user: UserRegister): """Register a new account""" # Check if email already exists existing = await db.get_account_by_email(user.email) if existing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" ) # Hash password password_hash = bcrypt.hashpw(user.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') # Create account account = await db.create_account( email=user.email, password_hash=password_hash, account_type="web" ) if not account: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create account" ) # Get characters for this account (should be empty for new account) characters = await db.get_characters_by_account_id(account["id"]) # Create access token with account_id (no character selected yet) access_token = create_access_token({ "account_id": account["id"], "character_id": None }) return { "access_token": access_token, "token_type": "bearer", "account": { "id": account["id"], "email": account["email"], "account_type": account["account_type"], "is_premium": account.get("premium_expires_at") is not None, }, "characters": characters, "needs_character_creation": len(characters) == 0 } @app.post("/api/auth/login") async def login(user: UserLogin): """Login with email and password""" # Get account by email account = await db.get_account_by_email(user.email) if not account: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password" ) # Verify password if not account.get('password_hash'): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password" ) if not bcrypt.checkpw(user.password.encode('utf-8'), account['password_hash'].encode('utf-8')): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password" ) # Update last login await db.update_account_last_login(account["id"]) # Get characters for this account characters = await db.get_characters_by_account_id(account["id"]) # Create access token with account_id (no character selected yet) access_token = create_access_token({ "account_id": account["id"], "character_id": None }) return { "access_token": access_token, "token_type": "bearer", "account": { "id": account["id"], "email": account["email"], "account_type": account["account_type"], "is_premium": account.get("premium_expires_at") is not None, }, "characters": [ { "id": char["id"], "name": char["name"], "level": char["level"], "xp": char["xp"], "hp": char["hp"], "max_hp": char["max_hp"], "stamina": char["stamina"], "max_stamina": char["max_stamina"], "strength": char["strength"], "agility": char["agility"], "endurance": char["endurance"], "intellect": char["intellect"], "avatar_data": char.get("avatar_data"), "last_played_at": char.get("last_played_at"), "location_id": char["location_id"], } for char in characters ], "needs_character_creation": len(characters) == 0 } @app.get("/api/auth/me") async def get_me(current_user: dict = Depends(get_current_user)): """Get current user profile""" return { "id": current_user["id"], "username": current_user.get("username"), "name": current_user["name"], "level": current_user["level"], "xp": current_user["xp"], "hp": current_user["hp"], "max_hp": current_user["max_hp"], "stamina": current_user["stamina"], "max_stamina": current_user["max_stamina"], "strength": current_user["strength"], "agility": current_user["agility"], "endurance": current_user["endurance"], "intellect": current_user["intellect"], "location_id": current_user["location_id"], "is_dead": current_user["is_dead"], "unspent_points": current_user["unspent_points"] } # ============================================================================ # Character Management Endpoints # ============================================================================ @app.get("/api/characters") async def list_characters(credentials: HTTPAuthorizationCredentials = Depends(security)): """List all characters for the logged-in account""" token = credentials.credentials payload = decode_token(token) account_id = payload.get("account_id") if not account_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) characters = await db.get_characters_by_account_id(account_id) return { "characters": [ { "id": char["id"], "name": char["name"], "level": char["level"], "xp": char["xp"], "hp": char["hp"], "max_hp": char["max_hp"], "stamina": char["stamina"], "max_stamina": char["max_stamina"], "avatar_data": char.get("avatar_data"), "location_id": char["location_id"], "created_at": char["created_at"], "last_played_at": char.get("last_played_at"), } for char in characters ] } @app.post("/api/characters") async def create_character_endpoint( character: CharacterCreate, credentials: HTTPAuthorizationCredentials = Depends(security) ): """Create a new character""" token = credentials.credentials payload = decode_token(token) account_id = payload.get("account_id") if not account_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) # Check if account can create more characters can_create, error_msg = await db.can_create_character(account_id) if not can_create: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=error_msg ) # Validate character name if len(character.name) < 3 or len(character.name) > 20: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Character name must be between 3 and 20 characters" ) # Check if name is unique existing = await db.get_character_by_name(character.name) if existing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Character name already taken" ) # Validate stat allocation (must total 20 points) total_stats = character.strength + character.agility + character.endurance + character.intellect if total_stats != 20: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Must allocate exactly 20 stat points (you allocated {total_stats})" ) # Validate each stat is >= 0 if any(stat < 0 for stat in [character.strength, character.agility, character.endurance, character.intellect]): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Stats cannot be negative" ) # Create character new_character = await db.create_character( account_id=account_id, name=character.name, strength=character.strength, agility=character.agility, endurance=character.endurance, intellect=character.intellect, avatar_data=character.avatar_data ) if not new_character: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create character" ) return { "message": "Character created successfully", "character": { "id": new_character["id"], "name": new_character["name"], "level": new_character["level"], "strength": new_character["strength"], "agility": new_character["agility"], "endurance": new_character["endurance"], "intellect": new_character["intellect"], "hp": new_character["hp"], "max_hp": new_character["max_hp"], "stamina": new_character["stamina"], "max_stamina": new_character["max_stamina"], "location_id": new_character["location_id"], "avatar_data": new_character.get("avatar_data"), } } @app.post("/api/characters/select") async def select_character( selection: CharacterSelect, credentials: HTTPAuthorizationCredentials = Depends(security) ): """Select a character to play""" token = credentials.credentials payload = decode_token(token) account_id = payload.get("account_id") if not account_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) # Verify character belongs to account character = await db.get_character_by_id(selection.character_id) if not character: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Character not found" ) if character["account_id"] != account_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Character does not belong to this account" ) # Update last played timestamp await db.update_character_last_played(selection.character_id) # Create new token with character_id access_token = create_access_token({ "account_id": account_id, "character_id": selection.character_id }) return { "access_token": access_token, "token_type": "bearer", "character": { "id": character["id"], "name": character["name"], "level": character["level"], "xp": character["xp"], "hp": character["hp"], "max_hp": character["max_hp"], "stamina": character["stamina"], "max_stamina": character["max_stamina"], "strength": character["strength"], "agility": character["agility"], "endurance": character["endurance"], "intellect": character["intellect"], "location_id": character["location_id"], "avatar_data": character.get("avatar_data"), } } @app.delete("/api/characters/{character_id}") async def delete_character_endpoint( character_id: int, credentials: HTTPAuthorizationCredentials = Depends(security) ): """Delete a character""" token = credentials.credentials payload = decode_token(token) account_id = payload.get("account_id") if not account_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) # Verify character belongs to account character = await db.get_character_by_id(character_id) if not character: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Character not found" ) if character["account_id"] != account_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Character does not belong to this account" ) # Delete character await db.delete_character(character_id) return { "message": f"Character '{character['name']}' deleted successfully" } # ============================================================================ # Game Endpoints # ============================================================================ @app.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 inventory and enrich with item data (exclude equipped items) inventory_raw = await db.get_inventory(player_id) inventory = [] total_weight = 0.0 total_volume = 0.0 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 # Only add non-equipped items to inventory list if not inv_item['is_equipped']: # Get unique item data if this is a unique item durability = None max_durability = None tier = 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') 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, "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 }) # 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 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') 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, "stats": item_def.stats, "encumbrance": item_def.encumbrance, "weapon_effects": item_def.weapon_effects if hasattr(item_def, 'weapon_effects') else {} } if slot not in equipment: equipment[slot] = None # Get combat state combat = await db.get_active_combat(player_id) # 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 }) # Calculate max weight and volume based on equipment # Base capacity max_weight = 10.0 # Base carrying capacity max_volume = 10.0 # Base volume capacity # Check for equipped backpack that increases capacity if equipment.get('backpack'): backpack_stats = equipment['backpack'].get('stats', {}) max_weight += backpack_stats.get('weight_capacity', 0) max_volume += backpack_stats.get('volume_capacity', 0) # 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 } @app.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 inventory and enrich with item data inventory_raw = await db.get_inventory(player_id) inventory = [] total_weight = 0.0 total_volume = 0.0 max_weight = 10.0 max_volume = 10.0 for inv_item in inventory_raw: item = ITEMS_MANAGER.get_item(inv_item['item_id']) if item: item_weight = item.weight * inv_item['quantity'] item_volume = item.volume * inv_item['quantity'] total_weight += item_weight total_volume += item_volume # Check for equipped bags/containers if inv_item['is_equipped'] and item.stats: max_weight += item.stats.get('weight_capacity', 0) max_volume += item.stats.get('volume_capacity', 0) # Enrich inventory item with all necessary data 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, "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 }) # 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, "inventory": inventory } @app.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 } @app.get("/api/game/location") async def get_current_location(current_user: dict = Depends(get_current_user)): """Get current location information""" 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.png" # 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." ) inventory_raw = await db.get_inventory(current_user['id']) 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: total_weight += item.weight * inv_item['quantity'] total_volume += item.volume * inv_item['quantity'] # Add capacity from equipped items (backpacks) if inv_item.get('is_equipped', False) and item.stats: max_weight += item.stats.get('weight_capacity', 0) max_volume += item.stats.get('volume_capacity', 0) # 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 for enemy in wandering_enemies: npcs_data.append({ "id": enemy['id'], "name": enemy['npc_id'].replace('_', ' ').title(), "type": "enemy", "level": enemy.get('level', 1), "is_wandering": True }) # 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"{npc_def.name 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 } @app.post("/api/game/move") async def move( move_req: MoveRequest, 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." ) success, message, new_location_id, stamina_cost, distance = await game_logic.move_player( current_user['id'], move_req.direction, LOCATIONS ) 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 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 ) # 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": f"/images/npcs/{enemy_id}.png", "turn": "player", "round": 1 } 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 @app.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 } @app.post("/api/game/interact") async def interact( interact_req: InteractRequest, current_user: dict = Depends(get_current_user) ): """Interact with an object""" # 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 {action_display} on {interactable_name}" }, "timestamp": datetime.utcnow().isoformat() } ) return result @app.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 @app.post("/api/game/pickup") async def pickup( pickup_req: PickupItemRequest, current_user: dict = Depends(get_current_user) ): """Pick up an item from the ground""" # 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 = item_def.name 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 # ============================================================================ # EQUIPMENT SYSTEM # ============================================================================ class EquipItemRequest(BaseModel): inventory_id: int # ID of item in inventory to equip class UnequipItemRequest(BaseModel): slot: str # Equipment slot to unequip from class RepairItemRequest(BaseModel): inventory_id: int # ID of item in inventory to repair @app.post("/api/game/equip") async def equip_item( equip_req: EquipItemRequest, current_user: dict = Depends(get_current_user) ): """Equip an item from inventory""" player_id = current_user['id'] # Get the inventory item inv_item = await db.get_inventory_item_by_id(equip_req.inventory_id) if not inv_item or inv_item['character_id'] != player_id: raise HTTPException(status_code=404, detail="Item not found in inventory") # Get item definition item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) if not item_def: raise HTTPException(status_code=404, detail="Item definition not found") # Check if item is equippable if not item_def.equippable or not item_def.slot: raise HTTPException(status_code=400, detail="This item cannot be equipped") # Check if slot is valid valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack'] if item_def.slot not in valid_slots: raise HTTPException(status_code=400, detail=f"Invalid equipment slot: {item_def.slot}") # Check if slot is already occupied current_equipped = await db.get_equipped_item_in_slot(player_id, item_def.slot) unequipped_item_name = None if current_equipped and current_equipped.get('item_id'): # Get the old item's name for the message old_inv_item = await db.get_inventory_item_by_id(current_equipped['item_id']) if old_inv_item: old_item_def = ITEMS_MANAGER.get_item(old_inv_item['item_id']) unequipped_item_name = old_item_def.name if old_item_def else "previous item" # Unequip current item first await db.unequip_item(player_id, item_def.slot) # Mark as not equipped in inventory await db.update_inventory_item(current_equipped['item_id'], is_equipped=False) # Equip the new item await db.equip_item(player_id, item_def.slot, equip_req.inventory_id) # Mark as equipped in inventory await db.update_inventory_item(equip_req.inventory_id, is_equipped=True) # Initialize unique_item if this is first time equipping an equippable with durability if inv_item.get('unique_item_id') is None and item_def.durability: # Create a unique_item instance for this equipment unique_item_id = await db.create_unique_item( item_id=item_def.id, durability=item_def.durability, max_durability=item_def.durability, tier=item_def.tier if hasattr(item_def, 'tier') else 1, unique_stats=None ) # Link the inventory item to this unique_item await db.update_inventory_item( equip_req.inventory_id, unique_item_id=unique_item_id ) # Build message if unequipped_item_name: message = f"Unequipped {unequipped_item_name}, equipped {item_def.name}" else: message = f"Equipped {item_def.name}" return { "success": True, "message": message, "slot": item_def.slot, "unequipped_item": unequipped_item_name } @app.post("/api/game/unequip") async def unequip_item( unequip_req: UnequipItemRequest, current_user: dict = Depends(get_current_user) ): """Unequip an item from equipment slot""" player_id = current_user['id'] # Check if slot is valid valid_slots = ['head', 'torso', 'legs', 'feet', 'weapon', 'offhand', 'backpack'] if unequip_req.slot not in valid_slots: raise HTTPException(status_code=400, detail=f"Invalid equipment slot: {unequip_req.slot}") # Get currently equipped item equipped = await db.get_equipped_item_in_slot(player_id, unequip_req.slot) if not equipped: raise HTTPException(status_code=400, detail=f"No item equipped in {unequip_req.slot} slot") # Get inventory item and item definition inv_item = await db.get_inventory_item_by_id(equipped['item_id']) item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) # Check if inventory has space (volume-wise) inventory = await db.get_inventory(player_id) total_volume = sum( ITEMS_MANAGER.get_item(i['item_id']).volume * i['quantity'] for i in inventory if ITEMS_MANAGER.get_item(i['item_id']) and not i['is_equipped'] ) # Get max volume (base 10 + backpack bonus) max_volume = 10.0 for inv in inventory: if inv['is_equipped']: item = ITEMS_MANAGER.get_item(inv['item_id']) if item and item.stats: max_volume += item.stats.get('volume_capacity', 0) # If unequipping backpack, check if items will fit if unequip_req.slot == 'backpack' and item_def.stats: backpack_volume = item_def.stats.get('volume_capacity', 0) if total_volume > (max_volume - backpack_volume): raise HTTPException( status_code=400, detail="Cannot unequip backpack: inventory would exceed volume capacity" ) # Check if adding this item would exceed volume if total_volume + item_def.volume > max_volume: # Drop to ground instead await db.unequip_item(player_id, unequip_req.slot) await db.update_inventory_item(equipped['item_id'], is_equipped=False) await db.drop_item(player_id, inv_item['item_id'], 1, current_user['location_id']) await db.remove_from_inventory(player_id, inv_item['item_id'], 1) return { "success": True, "message": f"Unequipped {item_def.name} (dropped to ground - inventory full)", "dropped": True } # Unequip the item await db.unequip_item(player_id, unequip_req.slot) await db.update_inventory_item(equipped['item_id'], is_equipped=False) return { "success": True, "message": f"Unequipped {item_def.name}", "dropped": False } @app.get("/api/game/equipment") async def get_equipment(current_user: dict = Depends(get_current_user)): """Get all equipped items""" player_id = current_user['id'] equipment = await db.get_all_equipment(player_id) # Enrich with item data enriched = {} for slot, item_data in equipment.items(): if item_data: inv_item = await db.get_inventory_item_by_id(item_data['item_id']) item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) if item_def: enriched[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": inv_item.get('durability'), "max_durability": inv_item.get('max_durability'), "tier": inv_item.get('tier', 1), "stats": item_def.stats, "encumbrance": item_def.encumbrance } else: enriched[slot] = None return {"equipment": enriched} @app.post("/api/game/repair_item") async def repair_item( repair_req: RepairItemRequest, current_user: dict = Depends(get_current_user) ): """Repair an item using materials at a workbench location""" player_id = current_user['id'] # Get player's location player = await db.get_player_by_id(player_id) location = LOCATIONS.get(player['location_id']) if not location: raise HTTPException(status_code=404, detail="Location not found") # Check if location has workbench location_tags = getattr(location, 'tags', []) if 'workbench' not in location_tags and 'repair_station' not in location_tags: raise HTTPException( status_code=400, detail="You need to be at a location with a workbench to repair items. Try the Gas Station!" ) # Get inventory item inv_item = await db.get_inventory_item(repair_req.inventory_id) if not inv_item or inv_item['character_id'] != player_id: raise HTTPException(status_code=404, detail="Item not found in inventory") # Get item definition item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) if not item_def: raise HTTPException(status_code=404, detail="Item definition not found") # Check if item is repairable if not getattr(item_def, 'repairable', False): raise HTTPException(status_code=400, detail=f"{item_def.name} cannot be repaired") # Check if item has durability (unique item) if not inv_item.get('unique_item_id'): raise HTTPException(status_code=400, detail="This item doesn't have durability tracking") # Get unique item data unique_item = await db.get_unique_item(inv_item['unique_item_id']) if not unique_item: raise HTTPException(status_code=500, detail="Unique item data not found") current_durability = unique_item.get('durability', 0) max_durability = unique_item.get('max_durability', 100) # Check if item needs repair if current_durability >= max_durability: raise HTTPException(status_code=400, detail=f"{item_def.name} is already at full durability") # Get repair materials repair_materials = getattr(item_def, 'repair_materials', []) if not repair_materials: raise HTTPException(status_code=500, detail="Item repair configuration missing") # Get repair tools repair_tools = getattr(item_def, 'repair_tools', []) # Check if player has all required materials and tools player_inventory = await db.get_inventory(player_id) inventory_dict = {item['item_id']: item['quantity'] for item in player_inventory} missing_materials = [] for material in repair_materials: required_qty = material.get('quantity', 1) available_qty = inventory_dict.get(material['item_id'], 0) if available_qty < required_qty: material_def = ITEMS_MANAGER.get_item(material['item_id']) material_name = material_def.name if material_def else material['item_id'] missing_materials.append(f"{material_name} ({available_qty}/{required_qty})") if missing_materials: raise HTTPException( status_code=400, detail=f"Missing materials: {', '.join(missing_materials)}" ) # Check and consume tools if required tools_consumed = [] if repair_tools: success, error_msg, tools_consumed = await consume_tool_durability(player_id, repair_tools, player_inventory) if not success: raise HTTPException(status_code=400, detail=error_msg) # Consume materials for material in repair_materials: await db.remove_item_from_inventory(player_id, material['item_id'], material['quantity']) # Calculate repair amount repair_percentage = getattr(item_def, 'repair_percentage', 25) repair_amount = int((max_durability * repair_percentage) / 100) new_durability = min(current_durability + repair_amount, max_durability) # Update unique item durability await db.update_unique_item(inv_item['unique_item_id'], durability=new_durability) # Build materials consumed message materials_used = [] for material in repair_materials: material_def = ITEMS_MANAGER.get_item(material['item_id']) emoji = material_def.emoji if material_def and hasattr(material_def, 'emoji') else '📦' name = material_def.name if material_def else material['item_id'] materials_used.append(f"{emoji} {name} x{material['quantity']}") return { "success": True, "message": f"Repaired {item_def.name}! Restored {repair_amount} durability.", "item_name": item_def.name, "old_durability": current_durability, "new_durability": new_durability, "max_durability": max_durability, "materials_consumed": materials_used, "tools_consumed": tools_consumed, "repair_amount": repair_amount } async def reduce_armor_durability(player_id: int, damage_taken: int) -> tuple: """ Reduce durability of equipped armor pieces when taking damage. Formula: durability_loss = max(1, (damage_taken / armor_value) * base_reduction_rate) Base reduction rate: 0.5 (so 10 damage with 5 armor = 1 durability loss) Returns: (armor_damage_absorbed, broken_armor_pieces) """ equipment = await db.get_all_equipment(player_id) armor_pieces = ['head', 'chest', 'legs', 'feet'] total_armor = 0 equipped_armor = [] # Collect all equipped armor for slot in armor_pieces: if equipment.get(slot) and equipment[slot]: armor_slot = equipment[slot] inv_item = await db.get_inventory_item_by_id(armor_slot['item_id']) if inv_item and inv_item.get('unique_item_id'): item_def = ITEMS_MANAGER.get_item(inv_item['item_id']) if item_def and item_def.stats and 'armor' in item_def.stats: armor_value = item_def.stats['armor'] total_armor += armor_value equipped_armor.append({ 'slot': slot, 'inv_item_id': armor_slot['item_id'], 'unique_item_id': inv_item['unique_item_id'], 'item_id': inv_item['item_id'], 'item_def': item_def, 'armor_value': armor_value }) if not equipped_armor: return 0, [] # Calculate damage absorbed by armor (total armor reduces damage) armor_absorbed = min(damage_taken // 2, total_armor) # Armor absorbs up to half the damage # Calculate durability loss for each armor piece # Balanced formula: armor should last many combats (10-20+ hits for low tier) base_reduction_rate = 0.1 # Reduced from 0.5 to make armor more durable broken_armor = [] for armor in equipped_armor: # Each piece takes durability loss proportional to its armor value proportion = armor['armor_value'] / total_armor if total_armor > 0 else 0 # Formula: durability_loss = (damage_taken * proportion / armor_value) * base_rate # This means higher armor value = less durability loss per hit # With base_rate = 0.1, a 5 armor piece taking 10 damage loses ~0.2 durability per hit durability_loss = max(1, int((damage_taken * proportion / max(armor['armor_value'], 1)) * base_reduction_rate * 10)) # Get current durability unique_item = await db.get_unique_item(armor['unique_item_id']) if unique_item: current_durability = unique_item.get('durability', 0) new_durability = max(0, current_durability - durability_loss) await db.update_unique_item(armor['unique_item_id'], durability=new_durability) # If armor broke, unequip and remove from inventory if new_durability <= 0: await db.unequip_item(player_id, armor['slot']) await db.remove_inventory_row(armor['inv_item_id']) broken_armor.append({ 'name': armor['item_def'].name, 'emoji': armor['item_def'].emoji, 'slot': armor['slot'] }) return armor_absorbed, broken_armor async def consume_tool_durability(user_id: int, tools: list, inventory: list) -> tuple: """ Consume durability from required tools. Returns: (success, error_message, consumed_tools_info) """ consumed_tools = [] tools_map = {} # Build map of available tools with durability for inv_item in inventory: if inv_item.get('unique_item_id'): unique_item = await db.get_unique_item(inv_item['unique_item_id']) if unique_item: item_id = inv_item['item_id'] durability = unique_item.get('durability', 0) if item_id not in tools_map: tools_map[item_id] = [] tools_map[item_id].append({ 'inventory_id': inv_item['id'], 'unique_item_id': inv_item['unique_item_id'], 'durability': durability, 'max_durability': unique_item.get('max_durability', 100) }) # Check and consume tools for tool_req in tools: tool_id = tool_req['item_id'] durability_cost = tool_req['durability_cost'] if tool_id not in tools_map or not tools_map[tool_id]: tool_def = ITEMS_MANAGER.items.get(tool_id) tool_name = tool_def.name if tool_def else tool_id return False, f"Missing required tool: {tool_name}", [] # Find tool with enough durability tool_found = None for tool in tools_map[tool_id]: if tool['durability'] >= durability_cost: tool_found = tool break if not tool_found: tool_def = ITEMS_MANAGER.items.get(tool_id) tool_name = tool_def.name if tool_def else tool_id return False, f"Tool {tool_name} doesn't have enough durability (need {durability_cost})", [] # Consume durability new_durability = tool_found['durability'] - durability_cost await db.update_unique_item(tool_found['unique_item_id'], durability=new_durability) # If tool breaks, remove from inventory if new_durability <= 0: await db.remove_inventory_row(tool_found['inventory_id']) tool_def = ITEMS_MANAGER.items.get(tool_id) consumed_tools.append({ 'item_id': tool_id, 'name': tool_def.name if tool_def else tool_id, 'durability_cost': durability_cost, 'broke': new_durability <= 0 }) return True, "", consumed_tools @app.get("/api/game/craftable") async def get_craftable_items(current_user: dict = Depends(get_current_user)): """Get all craftable items with material requirements and availability""" try: player = current_user # current_user is already the character dict if not player: raise HTTPException(status_code=404, detail="Player not found") # Get player's inventory with quantities inventory = await db.get_inventory(current_user['id']) inventory_counts = {} for inv_item in inventory: item_id = inv_item['item_id'] quantity = inv_item.get('quantity', 1) inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity craftable_items = [] for item_id, item_def in ITEMS_MANAGER.items.items(): if not getattr(item_def, 'craftable', False): continue craft_materials = getattr(item_def, 'craft_materials', []) if not craft_materials: continue # Check material availability materials_info = [] can_craft = True for material in craft_materials: mat_item_id = material['item_id'] required = material['quantity'] available = inventory_counts.get(mat_item_id, 0) mat_item_def = ITEMS_MANAGER.items.get(mat_item_id) materials_info.append({ 'item_id': mat_item_id, 'name': mat_item_def.name if mat_item_def else mat_item_id, 'emoji': mat_item_def.emoji if mat_item_def else '📦', 'required': required, 'available': available, 'has_enough': available >= required }) if available < required: can_craft = False # Check tool requirements craft_tools = getattr(item_def, 'craft_tools', []) tools_info = [] for tool_req in craft_tools: tool_id = tool_req['item_id'] durability_cost = tool_req['durability_cost'] tool_def = ITEMS_MANAGER.items.get(tool_id) # Check if player has this tool has_tool = False tool_durability = 0 for inv_item in inventory: if inv_item['item_id'] == tool_id and inv_item.get('unique_item_id'): unique = await db.get_unique_item(inv_item['unique_item_id']) if unique and unique.get('durability', 0) >= durability_cost: has_tool = 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': has_tool, 'tool_durability': tool_durability }) if not has_tool: can_craft = False # Check level requirement craft_level = getattr(item_def, 'craft_level', 1) player_level = player.get('level', 1) meets_level = player_level >= craft_level # Don't show recipes above player level if player_level < craft_level: continue if not meets_level: can_craft = False craftable_items.append({ 'item_id': item_id, 'name': item_def.name, 'emoji': item_def.emoji, 'description': item_def.description, 'tier': getattr(item_def, 'tier', 1), 'type': item_def.type, 'category': item_def.type, # Add category for filtering 'slot': getattr(item_def, 'slot', None), 'materials': materials_info, 'tools': tools_info, 'craft_level': craft_level, 'meets_level': meets_level, 'uncraftable': getattr(item_def, 'uncraftable', False), 'can_craft': can_craft }) # Sort: craftable items first, then by tier, then by name craftable_items.sort(key=lambda x: (not x['can_craft'], x['tier'], x['name'])) return {'craftable_items': craftable_items} except Exception as e: print(f"Error getting craftable items: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) class CraftItemRequest(BaseModel): item_id: str @app.post("/api/game/craft_item") async def craft_item(request: CraftItemRequest, current_user: dict = Depends(get_current_user)): """Craft an item, consuming materials and creating item with random stats for unique items""" try: player = current_user # current_user is already the character dict if not player: raise HTTPException(status_code=404, detail="Player not found") location_id = player['location_id'] location = LOCATIONS.get(location_id) # Check if player is at a workbench if not location or 'workbench' not in getattr(location, 'tags', []): raise HTTPException(status_code=400, detail="You must be at a workbench to craft items") # Get item definition item_def = ITEMS_MANAGER.items.get(request.item_id) if not item_def: raise HTTPException(status_code=404, detail="Item not found") if not getattr(item_def, 'craftable', False): raise HTTPException(status_code=400, detail="This item cannot be crafted") # Check level requirement craft_level = getattr(item_def, 'craft_level', 1) player_level = player.get('level', 1) if player_level < craft_level: raise HTTPException(status_code=400, detail=f"You need to be level {craft_level} to craft this item (you are level {player_level})") craft_materials = getattr(item_def, 'craft_materials', []) if not craft_materials: raise HTTPException(status_code=400, detail="No crafting recipe found") # Check if player has all materials inventory = await db.get_inventory(current_user['id']) inventory_counts = {} inventory_items_map = {} for inv_item in inventory: item_id = inv_item['item_id'] quantity = inv_item.get('quantity', 1) inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity if item_id not in inventory_items_map: inventory_items_map[item_id] = [] inventory_items_map[item_id].append(inv_item) # Check tools requirement craft_tools = getattr(item_def, 'craft_tools', []) if craft_tools: success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], craft_tools, inventory) if not success: raise HTTPException(status_code=400, detail=error_msg) else: tools_consumed = [] # Verify all materials are available for material in craft_materials: required = material['quantity'] available = inventory_counts.get(material['item_id'], 0) if available < required: raise HTTPException( status_code=400, detail=f"Not enough {material['item_id']}. Need {required}, have {available}" ) # Consume materials materials_used = [] for material in craft_materials: item_id = material['item_id'] quantity_needed = material['quantity'] items_of_type = inventory_items_map[item_id] for inv_item in items_of_type: if quantity_needed <= 0: break inv_quantity = inv_item.get('quantity', 1) to_remove = min(quantity_needed, inv_quantity) if inv_quantity > to_remove: # Update quantity await db.update_inventory_item( inv_item['id'], quantity=inv_quantity - to_remove ) else: # Remove entire stack - use item_id string, not inventory row id await db.remove_item_from_inventory(current_user['id'], item_id, to_remove) quantity_needed -= to_remove mat_item_def = ITEMS_MANAGER.items.get(item_id) materials_used.append({ 'item_id': item_id, 'name': mat_item_def.name if mat_item_def else item_id, 'quantity': material['quantity'] }) # Generate random stats for unique items import random created_item = None if hasattr(item_def, 'durability') and item_def.durability: # This is a unique item - generate random stats base_durability = item_def.durability # Random durability: 90-110% of base random_durability = int(base_durability * random.uniform(0.9, 1.1)) # Generate tier based on durability roll durability_percent = (random_durability / base_durability) if durability_percent >= 1.08: tier = 5 # Gold elif durability_percent >= 1.04: tier = 4 # Purple elif durability_percent >= 1.0: tier = 3 # Blue elif durability_percent >= 0.96: tier = 2 # Green else: tier = 1 # White # Generate random stats if item has stats random_stats = {} if hasattr(item_def, 'stats') and item_def.stats: for stat_key, stat_value in item_def.stats.items(): if isinstance(stat_value, (int, float)): # Random stat: 90-110% of base random_stats[stat_key] = int(stat_value * random.uniform(0.9, 1.1)) else: random_stats[stat_key] = stat_value # Create unique item in database unique_item_id = await db.create_unique_item( item_id=request.item_id, durability=random_durability, max_durability=random_durability, tier=tier, unique_stats=random_stats ) # Add to inventory await db.add_item_to_inventory( player_id=current_user['id'], item_id=request.item_id, quantity=1, unique_item_id=unique_item_id ) created_item = { 'item_id': request.item_id, 'name': item_def.name, 'emoji': item_def.emoji, 'tier': tier, 'durability': random_durability, 'max_durability': random_durability, 'stats': random_stats, 'unique': True } else: # Stackable item - just add to inventory await db.add_item_to_inventory( player_id=current_user['id'], item_id=request.item_id, quantity=1 ) created_item = { 'item_id': request.item_id, 'name': item_def.name, 'emoji': item_def.emoji, 'tier': getattr(item_def, 'tier', 1), 'unique': False } return { 'success': True, 'message': f"Successfully crafted {item_def.name}!", 'item': created_item, 'materials_consumed': materials_used, 'tools_consumed': tools_consumed } except Exception as e: print(f"Error crafting item: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) class UncraftItemRequest(BaseModel): inventory_id: int @app.post("/api/game/uncraft_item") async def uncraft_item(request: UncraftItemRequest, current_user: dict = Depends(get_current_user)): """Uncraft an item, returning materials with a chance of loss""" try: player = current_user # current_user is already the character dict if not player: raise HTTPException(status_code=404, detail="Player not found") location_id = player['location_id'] location = LOCATIONS.get(location_id) # Check if player is at a workbench if not location or 'workbench' not in getattr(location, 'tags', []): raise HTTPException(status_code=400, detail="You must be at a workbench to uncraft items") # Get inventory item inventory = await db.get_inventory(current_user['id']) inv_item = None for item in inventory: if item['id'] == request.inventory_id: inv_item = item break if not inv_item: raise HTTPException(status_code=404, detail="Item not found in inventory") # Get item definition item_def = ITEMS_MANAGER.items.get(inv_item['item_id']) if not item_def: raise HTTPException(status_code=404, detail="Item definition not found") if not getattr(item_def, 'uncraftable', False): raise HTTPException(status_code=400, detail="This item cannot be uncrafted") uncraft_yield = getattr(item_def, 'uncraft_yield', []) if not uncraft_yield: raise HTTPException(status_code=400, detail="No uncraft recipe found") # Check tools requirement uncraft_tools = getattr(item_def, 'uncraft_tools', []) if uncraft_tools: success, error_msg, tools_consumed = await consume_tool_durability(current_user['id'], uncraft_tools, inventory) if not success: raise HTTPException(status_code=400, detail=error_msg) else: tools_consumed = [] # Remove the item from inventory # Use remove_inventory_row since we have the inventory ID await db.remove_inventory_row(inv_item['id']) # Calculate durability ratio for yield reduction durability_ratio = 1.0 # Default: full yield if inv_item.get('unique_item_id'): unique_item = await db.get_unique_item(inv_item['unique_item_id']) if unique_item: current_durability = unique_item.get('durability', 0) max_durability = unique_item.get('max_durability', 1) if max_durability > 0: durability_ratio = current_durability / max_durability # Calculate materials with loss chance and durability reduction import random loss_chance = getattr(item_def, 'uncraft_loss_chance', 0.3) materials_yielded = [] materials_lost = [] for material in uncraft_yield: # Apply durability reduction first base_quantity = material['quantity'] adjusted_quantity = int(base_quantity * durability_ratio) # If durability is too low (< 10%), yield nothing for this material if durability_ratio < 0.1 or adjusted_quantity <= 0: mat_def = ITEMS_MANAGER.items.get(material['item_id']) materials_lost.append({ 'item_id': material['item_id'], 'name': mat_def.name if mat_def else material['item_id'], 'quantity': base_quantity, 'reason': 'durability_too_low' }) continue # Roll for each material separately with loss chance if random.random() < loss_chance: # Lost this material mat_def = ITEMS_MANAGER.items.get(material['item_id']) materials_lost.append({ 'item_id': material['item_id'], 'name': mat_def.name if mat_def else material['item_id'], 'quantity': adjusted_quantity, 'reason': 'random_loss' }) else: # Yield this material await db.add_item_to_inventory( player_id=current_user['id'], item_id=material['item_id'], quantity=adjusted_quantity ) mat_def = ITEMS_MANAGER.items.get(material['item_id']) materials_yielded.append({ 'item_id': material['item_id'], 'name': mat_def.name if mat_def else material['item_id'], 'emoji': mat_def.emoji if mat_def else '📦', 'quantity': adjusted_quantity }) message = f"Uncrafted {item_def.name}!" if durability_ratio < 1.0: message += f" (Item condition reduced yield by {int((1 - durability_ratio) * 100)}%)" if materials_lost: message += f" Lost {len(materials_lost)} material type(s) in the process." return { 'success': True, 'message': message, 'item_name': item_def.name, 'materials_yielded': materials_yielded, 'materials_lost': materials_lost, 'tools_consumed': tools_consumed, 'loss_chance': loss_chance, 'durability_ratio': round(durability_ratio, 2) } except Exception as e: print(f"Error uncrafting item: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/game/repairable") async def get_repairable_items(current_user: dict = Depends(get_current_user)): """Get all repairable items from inventory and equipped slots""" try: player = current_user # current_user is already the character dict if not player: raise HTTPException(status_code=404, detail="Player not found") location_id = player['location_id'] location = LOCATIONS.get(location_id) # Check if player is at a repair station if not location or 'repair_station' not in getattr(location, 'tags', []): raise HTTPException(status_code=400, detail="You must be at a repair station to repair items") repairable_items = [] # Check inventory items inventory = await db.get_inventory(current_user['id']) inventory_counts = {} for inv_item in inventory: item_id = inv_item['item_id'] quantity = inv_item.get('quantity', 1) inventory_counts[item_id] = inventory_counts.get(item_id, 0) + quantity for inv_item in inventory: if inv_item.get('unique_item_id'): unique_item = await db.get_unique_item(inv_item['unique_item_id']) if not unique_item: continue item_def = ITEMS_MANAGER.items.get(inv_item['item_id']) if not item_def or not getattr(item_def, 'repairable', False): continue current_durability = unique_item.get('durability', 0) max_durability = unique_item.get('max_durability', 100) needs_repair = current_durability < max_durability # Check materials availability repair_materials = getattr(item_def, 'repair_materials', []) materials_info = [] has_materials = True for material in repair_materials: mat_item_def = ITEMS_MANAGER.items.get(material['item_id']) available = inventory_counts.get(material['item_id'], 0) required = material['quantity'] materials_info.append({ 'item_id': material['item_id'], 'name': mat_item_def.name if mat_item_def else material['item_id'], 'emoji': mat_item_def.emoji if mat_item_def else '📦', 'quantity': required, 'available': available, 'has_enough': available >= required }) if available < required: has_materials = False # Check tools availability repair_tools = getattr(item_def, 'repair_tools', []) tools_info = [] has_tools = True for tool_req in repair_tools: tool_id = tool_req['item_id'] durability_cost = tool_req['durability_cost'] tool_def = ITEMS_MANAGER.items.get(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: has_tools = False can_repair = needs_repair and has_materials and has_tools repairable_items.append({ 'inventory_id': inv_item['id'], 'unique_item_id': inv_item['unique_item_id'], 'item_id': inv_item['item_id'], 'name': item_def.name, 'emoji': item_def.emoji, 'tier': unique_item.get('tier', 1), 'current_durability': current_durability, 'max_durability': max_durability, 'durability_percent': int((current_durability / max_durability) * 100), 'repair_percentage': getattr(item_def, 'repair_percentage', 25), 'needs_repair': needs_repair, 'materials': materials_info, 'tools': tools_info, 'can_repair': can_repair, 'location': 'inventory' }) # Check equipped items equipment_slots = ['head', 'weapon', 'torso', 'backpack', 'legs', 'feet'] for slot in equipment_slots: equipped_item_id = player.get(f'equipped_{slot}') if not equipped_item_id: continue unique_item = await db.get_unique_item(equipped_item_id) if not unique_item: continue item_id = unique_item['item_id'] item_def = ITEMS_MANAGER.items.get(item_id) if not item_def or not getattr(item_def, 'repairable', False): continue current_durability = unique_item.get('durability', 0) max_durability = unique_item.get('max_durability', 100) needs_repair = current_durability < max_durability # Check materials availability repair_materials = getattr(item_def, 'repair_materials', []) materials_info = [] has_materials = True for material in repair_materials: mat_item_def = ITEMS_MANAGER.items.get(material['item_id']) available = inventory_counts.get(material['item_id'], 0) required = material['quantity'] materials_info.append({ 'item_id': material['item_id'], 'name': mat_item_def.name if mat_item_def else material['item_id'], 'emoji': mat_item_def.emoji if mat_item_def else '📦', 'quantity': required, 'available': available, 'has_enough': available >= required }) if available < required: has_materials = False # Check tools availability repair_tools = getattr(item_def, 'repair_tools', []) tools_info = [] has_tools = True for tool_req in repair_tools: tool_id = tool_req['item_id'] durability_cost = tool_req['durability_cost'] tool_def = ITEMS_MANAGER.items.get(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: has_tools = False can_repair = needs_repair and has_materials and has_tools repairable_items.append({ 'unique_item_id': equipped_item_id, 'item_id': item_id, 'name': item_def.name, 'emoji': item_def.emoji, 'tier': unique_item.get('tier', 1), 'current_durability': current_durability, 'max_durability': max_durability, 'durability_percent': int((current_durability / max_durability) * 100), 'repair_percentage': getattr(item_def, 'repair_percentage', 25), 'needs_repair': needs_repair, 'materials': materials_info, 'tools': tools_info, 'can_repair': can_repair, 'location': 'equipped', 'slot': slot }) # Sort: repairable items first (can_repair=True), then by durability percent (lowest first), then by name repairable_items.sort(key=lambda x: (not x['can_repair'], -x['durability_percent'], x['name'])) return {'repairable_items': repairable_items} except Exception as e: print(f"Error getting repairable items: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/game/salvageable") async def get_salvageable_items(current_user: dict = Depends(get_current_user)): """Get list of salvageable (uncraftable) items from inventory with their unique stats""" try: player = current_user # current_user is already the character dict if not player: raise HTTPException(status_code=404, detail="Player not found") location_id = player['location_id'] location = LOCATIONS.get(location_id) # Check if player is at a workbench if not location or 'workbench' not in getattr(location, 'tags', []): return {'salvageable_items': [], 'at_workbench': False} # Get inventory inventory = await db.get_inventory(current_user['id']) salvageable_items = [] for inv_item in inventory: item_id = inv_item['item_id'] item_def = ITEMS_MANAGER.items.get(item_id) if not item_def or not getattr(item_def, 'uncraftable', False): continue # Get unique item details if it exists unique_item_data = None if inv_item.get('unique_item_id'): unique_item = await db.get_unique_item(inv_item['unique_item_id']) if unique_item: current_durability = unique_item.get('durability', 0) max_durability = unique_item.get('max_durability', 1) durability_percent = int((current_durability / max_durability) * 100) if max_durability > 0 else 0 # Get item stats from definition merged with unique stats item_stats = {} if item_def.stats: item_stats = dict(item_def.stats) if unique_item.get('unique_stats'): item_stats.update(unique_item.get('unique_stats')) unique_item_data = { 'current_durability': current_durability, 'max_durability': max_durability, 'durability_percent': durability_percent, 'tier': unique_item.get('tier', 1), 'unique_stats': item_stats # Includes both base stats and unique overrides } # Get uncraft yield uncraft_yield = getattr(item_def, 'uncraft_yield', []) yield_info = [] for material in uncraft_yield: mat_def = ITEMS_MANAGER.items.get(material['item_id']) yield_info.append({ 'item_id': material['item_id'], 'name': mat_def.name if mat_def else material['item_id'], 'emoji': mat_def.emoji if mat_def else '📦', 'quantity': material['quantity'] }) salvageable_items.append({ 'inventory_id': inv_item['id'], 'unique_item_id': inv_item.get('unique_item_id'), 'item_id': item_id, 'name': item_def.name, 'emoji': item_def.emoji, 'tier': getattr(item_def, 'tier', 1), 'quantity': inv_item['quantity'], 'unique_item_data': unique_item_data, 'base_yield': yield_info, 'loss_chance': getattr(item_def, 'uncraft_loss_chance', 0.3) }) return { 'salvageable_items': salvageable_items, 'at_workbench': True } except Exception as e: print(f"Error getting salvageable items: {e}") import traceback traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) class LootCorpseRequest(BaseModel): corpse_id: str item_index: Optional[int] = None # Index of specific item to loot (None = all) @app.get("/api/game/corpse/{corpse_id}") async def get_corpse_details( corpse_id: str, current_user: dict = Depends(get_current_user) ): """Get detailed information about a corpse's lootable items""" import json import sys sys.path.insert(0, '/app') from data.npcs import NPCS # Parse corpse ID corpse_type, corpse_db_id = corpse_id.split('_', 1) corpse_db_id = int(corpse_db_id) player = current_user # current_user is already the character dict # Get player's inventory to check available tools inventory = await db.get_inventory(player['id']) available_tools = set([item['item_id'] for item in inventory]) if corpse_type == 'npc': # Get NPC corpse corpse = await db.get_npc_corpse(corpse_db_id) if not corpse: raise HTTPException(status_code=404, detail="Corpse not found") if corpse['location_id'] != player['location_id']: raise HTTPException(status_code=400, detail="Corpse not at this location") # Parse remaining loot loot_remaining = json.loads(corpse['loot_remaining']) if corpse['loot_remaining'] else [] # Format loot items with tool requirements loot_items = [] for idx, loot_item in enumerate(loot_remaining): required_tool = loot_item.get('required_tool') item_def = ITEMS_MANAGER.get_item(loot_item['item_id']) has_tool = required_tool is None or required_tool in available_tools tool_def = ITEMS_MANAGER.get_item(required_tool) if required_tool else None loot_items.append({ 'index': idx, 'item_id': loot_item['item_id'], 'item_name': item_def.name if item_def else loot_item['item_id'], 'emoji': item_def.emoji if item_def else '📦', 'quantity_min': loot_item['quantity_min'], 'quantity_max': loot_item['quantity_max'], 'required_tool': required_tool, 'required_tool_name': tool_def.name if tool_def else required_tool, 'has_tool': has_tool, 'can_loot': has_tool }) npc_def = NPCS.get(corpse['npc_id']) return { 'corpse_id': corpse_id, 'type': 'npc', 'name': f"{npc_def.name if npc_def else corpse['npc_id']} Corpse", 'loot_items': loot_items, 'total_items': len(loot_items) } elif corpse_type == 'player': # Get player corpse corpse = await db.get_player_corpse(corpse_db_id) if not corpse: raise HTTPException(status_code=404, detail="Corpse not found") if corpse['location_id'] != player['location_id']: raise HTTPException(status_code=400, detail="Corpse not at this location") # Parse items items = json.loads(corpse['items']) if corpse['items'] else [] # Format items (player corpses don't require tools) loot_items = [] for idx, item in enumerate(items): item_def = ITEMS_MANAGER.get_item(item['item_id']) loot_items.append({ 'index': idx, 'item_id': item['item_id'], 'item_name': item_def.name if item_def else item['item_id'], 'emoji': item_def.emoji if item_def else '📦', 'quantity_min': item['quantity'], 'quantity_max': item['quantity'], 'required_tool': None, 'required_tool_name': None, 'has_tool': True, 'can_loot': True }) return { 'corpse_id': corpse_id, 'type': 'player', 'name': f"{corpse['player_name']}'s Corpse", 'loot_items': loot_items, 'total_items': len(loot_items) } else: raise HTTPException(status_code=400, detail="Invalid corpse type") @app.post("/api/game/loot_corpse") async def loot_corpse( req: LootCorpseRequest, current_user: dict = Depends(get_current_user) ): """Loot a corpse (NPC or player) - can loot specific item by index or all items""" import json import sys import random sys.path.insert(0, '/app') from data.npcs import NPCS # Parse corpse ID corpse_type, corpse_db_id = req.corpse_id.split('_', 1) corpse_db_id = int(corpse_db_id) player = current_user # current_user is already the character dict # Get player's current capacity current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(player['id']) if corpse_type == 'npc': # Get NPC corpse corpse = await db.get_npc_corpse(corpse_db_id) if not corpse: raise HTTPException(status_code=404, detail="Corpse not found") # Check if player is at the same location if corpse['location_id'] != player['location_id']: raise HTTPException(status_code=400, detail="Corpse not at this location") # Parse remaining loot loot_remaining = json.loads(corpse['loot_remaining']) if corpse['loot_remaining'] else [] if not loot_remaining: raise HTTPException(status_code=400, detail="Corpse has already been looted") # Get player's inventory to check tools inventory = await db.get_inventory(player['id']) available_tools = set([item['item_id'] for item in inventory]) looted_items = [] remaining_loot = [] dropped_items = [] # Items that couldn't fit in inventory tools_consumed = [] # Track tool durability consumed # If specific item index provided, loot only that item if req.item_index is not None: if req.item_index < 0 or req.item_index >= len(loot_remaining): raise HTTPException(status_code=400, detail="Invalid item index") loot_item = loot_remaining[req.item_index] required_tool = loot_item.get('required_tool') durability_cost = loot_item.get('tool_durability_cost', 5) # Default 5 durability per loot # Check if player has required tool and consume durability if required_tool: # Build tool requirement format for consume_tool_durability tool_req = [{ 'item_id': required_tool, 'durability_cost': durability_cost }] success, error_msg, tools_consumed = await consume_tool_durability(player['id'], tool_req, inventory) if not success: raise HTTPException(status_code=400, detail=error_msg) # Determine quantity quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max']) if quantity > 0: # Check if item fits in inventory item_def = ITEMS_MANAGER.get_item(loot_item['item_id']) if item_def: item_weight = item_def.weight * quantity item_volume = item_def.volume * quantity if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: # Item doesn't fit - drop it on ground await db.add_dropped_item(player['location_id'], loot_item['item_id'], quantity) dropped_items.append({ 'item_id': loot_item['item_id'], 'quantity': quantity, 'emoji': item_def.emoji }) else: # Item fits - add to inventory await db.add_item_to_inventory(player['id'], loot_item['item_id'], quantity) current_weight += item_weight current_volume += item_volume looted_items.append({ 'item_id': loot_item['item_id'], 'quantity': quantity }) # Remove this item from loot, keep others remaining_loot = [item for i, item in enumerate(loot_remaining) if i != req.item_index] else: # Loot all items that don't require tools or player has tools for for loot_item in loot_remaining: required_tool = loot_item.get('required_tool') durability_cost = loot_item.get('tool_durability_cost', 5) # If tool is required, consume durability can_loot = True if required_tool: tool_req = [{ 'item_id': required_tool, 'durability_cost': durability_cost }] # Check if player has tool with enough durability success, error_msg, consumed_info = await consume_tool_durability(player['id'], tool_req, inventory) if success: # Tool consumed successfully tools_consumed.extend(consumed_info) # Refresh inventory after tool consumption inventory = await db.get_inventory(player['id']) else: # Can't loot this item can_loot = False if can_loot: # Can loot this item quantity = random.randint(loot_item['quantity_min'], loot_item['quantity_max']) if quantity > 0: # Check if item fits in inventory item_def = ITEMS_MANAGER.get_item(loot_item['item_id']) if item_def: item_weight = item_def.weight * quantity item_volume = item_def.volume * quantity if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: # Item doesn't fit - drop it on ground await db.add_dropped_item(player['location_id'], loot_item['item_id'], quantity) dropped_items.append({ 'item_id': loot_item['item_id'], 'quantity': quantity, 'emoji': item_def.emoji }) else: # Item fits - add to inventory await db.add_item_to_inventory(player['id'], loot_item['item_id'], quantity) current_weight += item_weight current_volume += item_volume looted_items.append({ 'item_id': loot_item['item_id'], 'quantity': quantity }) else: # Keep in corpse remaining_loot.append(loot_item) # Update or remove corpse if remaining_loot: await db.update_npc_corpse(corpse_db_id, json.dumps(remaining_loot)) else: await db.remove_npc_corpse(corpse_db_id) # Build response message message_parts = [] for item in looted_items: item_def = ITEMS_MANAGER.get_item(item['item_id']) item_name = item_def.name if item_def else item['item_id'] message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}") dropped_parts = [] for item in dropped_items: item_def = ITEMS_MANAGER.get_item(item['item_id']) item_name = item_def.name if item_def else item['item_id'] dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}") message = "" if message_parts: message = "Looted: " + ", ".join(message_parts) if dropped_parts: if message: message += "\n" message += "⚠️ Backpack full! Dropped on ground: " + ", ".join(dropped_parts) if not message_parts and not dropped_parts: message = "Nothing could be looted" if remaining_loot and req.item_index is None: message += f"\n{len(remaining_loot)} item(s) require tools to extract" # Broadcast to location about corpse looting if len(remaining_loot) == 0: # Corpse fully looted await manager.send_to_location( location_id=player['location_id'], message={ "type": "location_update", "data": { "message": f"{player['name']} fully looted an NPC corpse", "action": "corpse_looted" }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] ) return { "success": True, "message": message, "looted_items": looted_items, "dropped_items": dropped_items, "tools_consumed": tools_consumed, "corpse_empty": len(remaining_loot) == 0, "remaining_count": len(remaining_loot) } elif corpse_type == 'player': # Get player corpse corpse = await db.get_player_corpse(corpse_db_id) if not corpse: raise HTTPException(status_code=404, detail="Corpse not found") if corpse['location_id'] != player['location_id']: raise HTTPException(status_code=400, detail="Corpse not at this location") # Parse items items = json.loads(corpse['items']) if corpse['items'] else [] if not items: raise HTTPException(status_code=400, detail="Corpse has no items") looted_items = [] remaining_items = [] dropped_items = [] # Items that couldn't fit in inventory # If specific item index provided, loot only that item if req.item_index is not None: if req.item_index < 0 or req.item_index >= len(items): raise HTTPException(status_code=400, detail="Invalid item index") item = items[req.item_index] # Check if item fits in inventory item_def = ITEMS_MANAGER.get_item(item['item_id']) if item_def: item_weight = item_def.weight * item['quantity'] item_volume = item_def.volume * item['quantity'] if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: # Item doesn't fit - drop it on ground await db.add_dropped_item(player['location_id'], item['item_id'], item['quantity']) dropped_items.append({ 'item_id': item['item_id'], 'quantity': item['quantity'], 'emoji': item_def.emoji }) else: # Item fits - add to inventory await db.add_item_to_inventory(player['id'], item['item_id'], item['quantity']) looted_items.append(item) # Remove this item, keep others remaining_items = [it for i, it in enumerate(items) if i != req.item_index] else: # Loot all items for item in items: # Check if item fits in inventory item_def = ITEMS_MANAGER.get_item(item['item_id']) if item_def: item_weight = item_def.weight * item['quantity'] item_volume = item_def.volume * item['quantity'] if current_weight + item_weight > max_weight or current_volume + item_volume > max_volume: # Item doesn't fit - drop it on ground await db.add_dropped_item(player['location_id'], item['item_id'], item['quantity']) dropped_items.append({ 'item_id': item['item_id'], 'quantity': item['quantity'], 'emoji': item_def.emoji }) else: # Item fits - add to inventory await db.add_item_to_inventory(player['id'], item['item_id'], item['quantity']) current_weight += item_weight current_volume += item_volume looted_items.append(item) # Update or remove corpse if remaining_items: await db.update_player_corpse(corpse_db_id, json.dumps(remaining_items)) else: await db.remove_player_corpse(corpse_db_id) # Build message message_parts = [] for item in looted_items: item_def = ITEMS_MANAGER.get_item(item['item_id']) item_name = item_def.name if item_def else item['item_id'] message_parts.append(f"{item_def.emoji if item_def else ''} {item_name} x{item['quantity']}") dropped_parts = [] for item in dropped_items: item_def = ITEMS_MANAGER.get_item(item['item_id']) item_name = item_def.name if item_def else item['item_id'] dropped_parts.append(f"{item.get('emoji', '📦')} {item_name} x{item['quantity']}") message = "" if message_parts: message = "Looted: " + ", ".join(message_parts) if dropped_parts: if message: message += "\n" message += "⚠️ Backpack full! Dropped on ground: " + ", ".join(dropped_parts) if not message_parts and not dropped_parts: message = "Nothing could be looted" # Broadcast to location about corpse looting if len(remaining_items) == 0: # Corpse fully looted - broadcast removal await manager.send_to_location( location_id=player['location_id'], message={ "type": "location_update", "data": { "message": f"{player['name']} fully looted {corpse['player_name']}'s corpse", "action": "player_corpse_emptied", "corpse_id": req.corpse_id }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] ) else: # Corpse partially looted - broadcast item updates await manager.send_to_location( location_id=player['location_id'], message={ "type": "location_update", "data": { "message": f"{player['name']} looted from {corpse['player_name']}'s corpse", "action": "player_corpse_looted", "corpse_id": req.corpse_id, "remaining_items": remaining_items, "looted_item_ids": [item['item_id'] for item in looted_items] if req.item_index is not None else None }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] ) return { "success": True, "message": message, "looted_items": looted_items, "dropped_items": dropped_items, "corpse_empty": len(remaining_items) == 0, "remaining_count": len(remaining_items) } else: raise HTTPException(status_code=400, detail="Invalid corpse type") # ============================================================================ # Combat Endpoints # ============================================================================ @app.get("/api/game/combat") async def get_combat_status(current_user: dict = Depends(get_current_user)): """Get current combat status""" combat = await db.get_active_combat(current_user['id']) if not combat: return {"in_combat": False} # Load NPC data from npcs.json import sys sys.path.insert(0, '/app') from data.npcs import NPCS npc_def = NPCS.get(combat['npc_id']) return { "in_combat": True, "combat": { "npc_id": combat['npc_id'], "npc_name": npc_def.name if npc_def else combat['npc_id'].replace('_', ' ').title(), "npc_hp": combat['npc_hp'], "npc_max_hp": combat['npc_max_hp'], "npc_image": f"/images/npcs/{combat['npc_id']}.png" if npc_def else None, "turn": combat['turn'], "round": combat.get('round', 1) } } @app.post("/api/game/combat/initiate") async def initiate_combat( req: InitiateCombatRequest, current_user: dict = Depends(get_current_user) ): """Start combat with a wandering enemy""" import random import sys sys.path.insert(0, '/app') from data.npcs import NPCS # Check if already in combat existing_combat = await db.get_active_combat(current_user['id']) if existing_combat: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Already in combat" ) # Get enemy from wandering_enemies table async with db.DatabaseSession() as session: from sqlalchemy import select stmt = select(db.wandering_enemies).where(db.wandering_enemies.c.id == req.enemy_id) result = await session.execute(stmt) enemy = result.fetchone() if not enemy: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Enemy not found" ) # Get NPC definition npc_def = NPCS.get(enemy.npc_id) if not npc_def: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="NPC definition not found" ) # Randomize HP npc_hp = random.randint(npc_def.hp_min, npc_def.hp_max) # Create combat combat = await db.create_combat( player_id=current_user['id'], npc_id=enemy.npc_id, npc_hp=npc_hp, npc_max_hp=npc_hp, location_id=current_user['location_id'], from_wandering=True ) # Remove the wandering enemy from the location async with db.DatabaseSession() as session: from sqlalchemy import delete stmt = delete(db.wandering_enemies).where(db.wandering_enemies.c.id == req.enemy_id) await session.execute(stmt) await session.commit() # Track combat initiation await db.update_player_statistics(current_user['id'], combats_initiated=1, increment=True) # Get player info for broadcasts player = current_user # current_user is already the character dict # Send WebSocket update to the player await manager.send_personal_message(current_user['id'], { "type": "combat_started", "data": { "message": f"Combat started with {npc_def.name}!", "combat": { "npc_id": enemy.npc_id, "npc_name": npc_def.name, "npc_hp": npc_hp, "npc_max_hp": npc_hp, "npc_image": f"/images/npcs/{enemy.npc_id}.png", "turn": "player", "round": 1 } }, "timestamp": datetime.utcnow().isoformat() }) # Broadcast to location that player entered combat await manager.send_to_location( location_id=current_user['location_id'], message={ "type": "location_update", "data": { "message": f"{player['name']} entered combat with {npc_def.name}", "action": "combat_started", "player_id": player['id'] }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=current_user['id'] ) return { "success": True, "message": f"Combat started with {npc_def.name}!", "combat": { "npc_id": enemy.npc_id, "npc_name": npc_def.name, "npc_hp": npc_hp, "npc_max_hp": npc_hp, "npc_image": f"/images/npcs/{enemy.npc_id}.png", "turn": "player", "round": 1 } } @app.post("/api/game/combat/action") async def combat_action( req: CombatActionRequest, current_user: dict = Depends(get_current_user) ): """Perform a combat action""" import random import sys sys.path.insert(0, '/app') from data.npcs import NPCS # Get active combat combat = await db.get_active_combat(current_user['id']) if not combat: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Not in combat" ) if combat['turn'] != 'player': raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Not your turn" ) # Get player and NPC data player = current_user # current_user is already the character dict npc_def = NPCS.get(combat['npc_id']) result_message = "" combat_over = False player_won = False if req.action == 'attack': # Calculate player damage base_damage = 5 strength_bonus = player['strength'] // 2 level_bonus = player['level'] weapon_damage = 0 weapon_effects = {} weapon_inv_id = None # Check for equipped weapon equipment = await db.get_all_equipment(player['id']) if equipment.get('weapon') and equipment['weapon']: weapon_slot = equipment['weapon'] inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id']) if inv_item: weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id']) if weapon_def and weapon_def.stats: weapon_damage = random.randint( weapon_def.stats.get('damage_min', 0), weapon_def.stats.get('damage_max', 0) ) weapon_effects = weapon_def.weapon_effects if hasattr(weapon_def, 'weapon_effects') else {} weapon_inv_id = weapon_slot['item_id'] # Check encumbrance penalty (higher encumbrance = chance to miss) encumbrance = player.get('encumbrance', 0) attack_failed = False if encumbrance > 0: miss_chance = min(0.3, encumbrance * 0.05) # Max 30% miss chance if random.random() < miss_chance: attack_failed = True variance = random.randint(-2, 2) damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) if attack_failed: result_message = f"Your attack misses due to heavy encumbrance! " new_npc_hp = combat['npc_hp'] else: # Apply damage to NPC new_npc_hp = max(0, combat['npc_hp'] - damage) result_message = f"You attack for {damage} damage! " # Apply weapon effects if weapon_effects and 'bleeding' in weapon_effects: bleeding = weapon_effects['bleeding'] if random.random() < bleeding.get('chance', 0): # Apply bleeding effect (would need combat effects table, for now just bonus damage) bleed_damage = bleeding.get('damage', 0) new_npc_hp = max(0, new_npc_hp - bleed_damage) result_message += f"💉 Bleeding effect! +{bleed_damage} damage! " # Decrease weapon durability (from unique_item) if weapon_inv_id and inv_item.get('unique_item_id'): new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1) if new_durability is None: # Weapon broke (unique_item was deleted, cascades to inventory) result_message += "\n⚠️ Your weapon broke! " await db.unequip_item(player['id'], 'weapon') if new_npc_hp <= 0: # NPC defeated result_message += f"{npc_def.name} has been defeated!" combat_over = True player_won = True # Award XP xp_gained = npc_def.xp_reward new_xp = player['xp'] + xp_gained result_message += f"\n+{xp_gained} XP" await db.update_player(player['id'], xp=new_xp) # Track kill statistics await db.update_player_statistics(player['id'], enemies_killed=1, damage_dealt=damage, increment=True) # Check for level up level_up_result = await game_logic.check_and_apply_level_up(player['id']) if level_up_result['leveled_up']: result_message += f"\n🎉 Level Up! You are now level {level_up_result['new_level']}!" result_message += f"\n+{level_up_result['levels_gained']} stat point(s) to spend!" # Create corpse with loot import json corpse_loot = npc_def.corpse_loot if hasattr(npc_def, 'corpse_loot') else [] # Convert CorpseLoot objects to dicts corpse_loot_dicts = [] for loot in corpse_loot: if hasattr(loot, '__dict__'): corpse_loot_dicts.append({ 'item_id': loot.item_id, 'quantity_min': loot.quantity_min, 'quantity_max': loot.quantity_max, 'required_tool': loot.required_tool }) else: corpse_loot_dicts.append(loot) await db.create_npc_corpse( npc_id=combat['npc_id'], location_id=player['location_id'], loot_remaining=json.dumps(corpse_loot_dicts) ) await db.end_combat(player['id']) # Update Redis: Delete combat state cache if redis_manager: await redis_manager.delete_combat_state(player['id']) # Update player session await redis_manager.update_player_session_field(player['id'], 'xp', new_xp) if level_up_result['leveled_up']: await redis_manager.update_player_session_field(player['id'], 'level', level_up_result['new_level']) # Broadcast to location that combat ended and corpse appeared await manager.send_to_location( location_id=player['location_id'], message={ "type": "location_update", "data": { "message": f"{player['name']} defeated {npc_def.name}", "action": "combat_ended", "player_id": player['id'], "corpse_created": True }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] ) else: # NPC's turn - use shared logic npc_attack_message, player_defeated = await game_logic.npc_attack( player['id'], {'npc_hp': new_npc_hp, 'npc_max_hp': combat['npc_max_hp']}, npc_def, reduce_armor_durability ) result_message += f"\n{npc_attack_message}" if player_defeated: combat_over = True else: # Update NPC HP (combat turn already updated by npc_attack) await db.update_combat(player['id'], { 'npc_hp': new_npc_hp }) elif req.action == 'flee': # 50% chance to flee if random.random() < 0.5: result_message = "You successfully fled from combat!" combat_over = True player_won = False # Fled, not won # Track successful flee await db.update_player_statistics(player['id'], successful_flees=1, increment=True) # Respawn the enemy back to the location if it came from wandering if combat.get('from_wandering_enemy'): # Respawn enemy with current HP at the combat location import time despawn_time = time.time() + 300 # 5 minutes async with db.DatabaseSession() as session: from sqlalchemy import insert stmt = insert(db.wandering_enemies).values( npc_id=combat['npc_id'], location_id=combat['location_id'], spawn_timestamp=time.time(), despawn_timestamp=despawn_time ) await session.execute(stmt) await session.commit() await db.end_combat(player['id']) # Broadcast to location that player fled from combat await manager.send_to_location( location_id=combat['location_id'], message={ "type": "location_update", "data": { "message": f"{player['name']} fled from combat", "action": "combat_fled", "player_id": player['id'] }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] ) else: # Failed to flee, NPC attacks npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max) new_player_hp = max(0, player['hp'] - npc_damage) result_message = f"Failed to flee! {npc_def.name} attacks for {npc_damage} damage!" if new_player_hp <= 0: result_message += "\nYou have been defeated!" combat_over = True await db.update_player(player['id'], hp=0, is_dead=True) await db.update_player_statistics(player['id'], deaths=1, failed_flees=1, damage_taken=npc_damage, increment=True) # Create corpse with player's inventory import json import time as time_module inventory = await db.get_inventory(player['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') }) logger.info(f"Creating player corpse (failed flee) for {player['name']} at {combat['location_id']} with {len(inventory_items)} items") corpse_id = await db.create_player_corpse( player_name=player['name'], location_id=combat['location_id'], items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) ) logger.info(f"Successfully created player corpse (failed flee): ID={corpse_id}, player={player['name']}, location={combat['location_id']}, items_count={len(inventory_items)}") # Clear player's inventory (items are now in corpse) await db.clear_inventory(player['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, "timestamp": time_module.time() } # Respawn enemy if from wandering if combat.get('from_wandering_enemy'): import time despawn_time = time.time() + 300 async with db.DatabaseSession() as session: from sqlalchemy import insert stmt = insert(db.wandering_enemies).values( npc_id=combat['npc_id'], location_id=combat['location_id'], spawn_timestamp=time.time(), despawn_timestamp=despawn_time ) await session.execute(stmt) await session.commit() await db.end_combat(player['id']) # Broadcast to location that player died and corpse appeared logger.info(f"Broadcasting player_died (failed flee) to location {combat['location_id']} for player {player['name']}") await manager.send_to_location( location_id=combat['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 }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] ) else: # Player survived, update HP and turn back to player await db.update_player(player['id'], hp=new_player_hp) await db.update_player_statistics(player['id'], failed_flees=1, damage_taken=npc_damage, increment=True) await db.update_combat(player['id'], {'turn': 'player'}) # Get updated combat state if not over updated_combat = None if not combat_over: raw_combat = await db.get_active_combat(current_user['id']) if raw_combat: updated_combat = { "npc_id": raw_combat['npc_id'], "npc_name": npc_def.name, "npc_hp": raw_combat['npc_hp'], "npc_max_hp": raw_combat['npc_max_hp'], "npc_image": f"/images/npcs/{raw_combat['npc_id']}.png", "turn": raw_combat['turn'] } # Get fresh player data with updated HP after NPC attack updated_player = await db.get_player_by_id(current_user['id']) if not updated_player: updated_player = current_user # Fallback to current_user if something went wrong # Broadcast combat update via WebSocket await manager.send_personal_message(current_user['id'], { "type": "combat_update", "data": { "message": result_message, "log_entry": result_message, # This should be APPENDED to combat log, not replace it "combat_over": combat_over, "player_won": player_won if combat_over else None, "combat": updated_combat, "player": { "hp": updated_player['hp'], "xp": updated_player['xp'], "level": updated_player['level'] } }, "timestamp": datetime.utcnow().isoformat() }) return { "success": True, "message": result_message, "combat_over": combat_over, "player_won": player_won if combat_over else None, "combat": updated_combat if updated_combat else None } # ============================================================================ # PvP Combat Endpoints # ============================================================================ class PvPCombatInitiateRequest(BaseModel): target_player_id: int @app.post("/api/game/pvp/initiate") async def initiate_pvp_combat( req: PvPCombatInitiateRequest, current_user: dict = Depends(get_current_user) ): """Initiate PvP combat with another player""" # Get attacker (current user) attacker = await db.get_player_by_id(current_user['id']) if not attacker: raise HTTPException(status_code=404, detail="Player not found") # Check if attacker is already in combat existing_combat = await db.get_active_combat(attacker['id']) if existing_combat: raise HTTPException(status_code=400, detail="You are already in PvE combat") existing_pvp = await db.get_pvp_combat_by_player(attacker['id']) if existing_pvp: raise HTTPException(status_code=400, detail="You are already in PvP combat") # Get defender (target player) defender = await db.get_player_by_id(req.target_player_id) if not defender: raise HTTPException(status_code=404, detail="Target player not found") # Check if defender is in combat defender_pve = await db.get_active_combat(defender['id']) if defender_pve: raise HTTPException(status_code=400, detail="Target player is in PvE combat") defender_pvp = await db.get_pvp_combat_by_player(defender['id']) if defender_pvp: raise HTTPException(status_code=400, detail="Target player is in PvP combat") # Check same location if attacker['location_id'] != defender['location_id']: raise HTTPException(status_code=400, detail="Target player is not in your location") # Check danger level (>= 3 required for PvP) location = LOCATIONS.get(attacker['location_id']) if not location or location.danger_level < 3: raise HTTPException(status_code=400, detail="PvP combat is only allowed in dangerous zones (danger level >= 3)") # Check level difference (+/- 3 levels) level_diff = abs(attacker['level'] - defender['level']) if level_diff > 3: raise HTTPException( status_code=400, detail=f"Level difference too large! You can only fight players within 3 levels (target is level {defender['level']})" ) # Create PvP combat pvp_combat = await db.create_pvp_combat( attacker_id=attacker['id'], defender_id=defender['id'], location_id=attacker['location_id'], turn_timeout=300 # 5 minutes ) # Track PvP combat initiation await db.update_player_statistics(attacker['id'], pvp_combats_initiated=1, increment=True) # Send WebSocket notifications to both players await manager.send_personal_message(attacker['id'], { "type": "combat_started", "data": { "message": f"You have initiated combat with {defender['name']}! They get the first turn.", "pvp_combat": pvp_combat }, "timestamp": datetime.utcnow().isoformat() }) await manager.send_personal_message(defender['id'], { "type": "combat_started", "data": { "message": f"{attacker['name']} has challenged you to PvP combat! It's your turn.", "pvp_combat": pvp_combat }, "timestamp": datetime.utcnow().isoformat() }) return { "success": True, "message": f"You have initiated combat with {defender['name']}! They get the first turn.", "pvp_combat": pvp_combat } @app.get("/api/game/pvp/status") async def get_pvp_combat_status(current_user: dict = Depends(get_current_user)): """Get current PvP combat status""" pvp_combat = await db.get_pvp_combat_by_player(current_user['id']) if not pvp_combat: return {"in_pvp_combat": False, "pvp_combat": None} # Check if current player has already acknowledged - if so, don't show combat anymore is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] if (is_attacker and pvp_combat.get('attacker_acknowledged', False)) or \ (not is_attacker and pvp_combat.get('defender_acknowledged', False)): return {"in_pvp_combat": False, "pvp_combat": None} # Get both players' data attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) defender = await db.get_player_by_id(pvp_combat['defender_character_id']) # Determine if current user is attacker or defender is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] your_turn = (is_attacker and pvp_combat['turn'] == 'attacker') or \ (not is_attacker and pvp_combat['turn'] == 'defender') # Calculate time remaining for turn import time time_elapsed = time.time() - pvp_combat['turn_started_at'] time_remaining = max(0, pvp_combat['turn_timeout_seconds'] - time_elapsed) # Auto-advance if time expired if time_remaining == 0 and your_turn: # Skip turn new_turn = 'defender' if is_attacker else 'attacker' await db.update_pvp_combat(pvp_combat['id'], { 'turn': new_turn, 'turn_started_at': time.time() }) pvp_combat = await db.get_pvp_combat_by_id(pvp_combat['id']) your_turn = False time_remaining = pvp_combat['turn_timeout_seconds'] return { "in_pvp_combat": True, "pvp_combat": { "id": pvp_combat['id'], "attacker": { "id": attacker['id'], "username": attacker['name'], "level": attacker['level'], "hp": attacker['hp'], # Use actual player HP "max_hp": attacker['max_hp'] }, "defender": { "id": defender['id'], "username": defender['name'], "level": defender['level'], "hp": defender['hp'], # Use actual player HP "max_hp": defender['max_hp'] }, "is_attacker": is_attacker, "your_turn": your_turn, "current_turn": pvp_combat['turn'], "time_remaining": int(time_remaining), "location_id": pvp_combat['location_id'], "last_action": pvp_combat.get('last_action'), "combat_over": pvp_combat.get('attacker_fled', False) or pvp_combat.get('defender_fled', False) or \ attacker['hp'] <= 0 or defender['hp'] <= 0, "attacker_fled": pvp_combat.get('attacker_fled', False), "defender_fled": pvp_combat.get('defender_fled', False) } } class PvPAcknowledgeRequest(BaseModel): combat_id: int @app.post("/api/game/pvp/acknowledge") async def acknowledge_pvp_combat( req: PvPAcknowledgeRequest, current_user: dict = Depends(get_current_user) ): """Acknowledge PvP combat end""" await db.acknowledge_pvp_combat(req.combat_id, current_user['id']) # Broadcast to location that player has returned player = current_user # current_user is already the character dict if player: await manager.send_to_location( location_id=player['location_id'], message={ "type": "player_arrived", "data": { "player_id": player['id'], "username": player['name'], "message": f"{player['name']} has returned from PvP combat." }, "timestamp": datetime.utcnow().isoformat() }, exclude_player_id=player['id'] ) return {"success": True} class PvPCombatActionRequest(BaseModel): action: str # 'attack', 'flee', 'use_item' item_id: Optional[str] = None # For use_item action @app.post("/api/game/pvp/action") async def pvp_combat_action( req: PvPCombatActionRequest, current_user: dict = Depends(get_current_user) ): """Perform a PvP combat action""" import random import time # Get PvP combat pvp_combat = await db.get_pvp_combat_by_player(current_user['id']) if not pvp_combat: raise HTTPException(status_code=400, detail="Not in PvP combat") # Determine roles is_attacker = pvp_combat['attacker_character_id'] == current_user['id'] your_turn = (is_attacker and pvp_combat['turn'] == 'attacker') or \ (not is_attacker and pvp_combat['turn'] == 'defender') if not your_turn: raise HTTPException(status_code=400, detail="It's not your turn") # Get both players attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) defender = await db.get_player_by_id(pvp_combat['defender_character_id']) current_player = attacker if is_attacker else defender opponent = defender if is_attacker else attacker result_message = "" combat_over = False winner_id = None if req.action == 'attack': # Calculate damage (similar to PvE) base_damage = 5 strength_bonus = current_player['strength'] * 2 level_bonus = current_player['level'] # Check for equipped weapon weapon_damage = 0 equipment = await db.get_all_equipment(current_player['id']) if equipment.get('weapon') and equipment['weapon']: weapon_slot = equipment['weapon'] inv_item = await db.get_inventory_item_by_id(weapon_slot['item_id']) if inv_item: weapon_def = ITEMS_MANAGER.get_item(inv_item['item_id']) if weapon_def and weapon_def.stats: weapon_damage = random.randint( weapon_def.stats.get('damage_min', 0), weapon_def.stats.get('damage_max', 0) ) # Decrease weapon durability if inv_item.get('unique_item_id'): new_durability = await db.decrease_unique_item_durability(inv_item['unique_item_id'], 1) if new_durability is None: result_message += "⚠️ Your weapon broke! " await db.unequip_item(current_player['id'], 'weapon') variance = random.randint(-2, 2) damage = max(1, base_damage + strength_bonus + level_bonus + weapon_damage + variance) # Apply armor reduction and durability loss to opponent armor_absorbed, broken_armor = await reduce_armor_durability(opponent['id'], damage) actual_damage = max(1, damage - armor_absorbed) # Update opponent HP (use actual player HP, not pvp_combat fields) new_opponent_hp = max(0, opponent['hp'] - actual_damage) # Update opponent's HP in database await db.update_player(opponent['id'], hp=new_opponent_hp) # Store message with attacker's username so both players can see it correctly stored_message = f"{current_player['name']} attacks {opponent['name']} for {damage} damage!" if armor_absorbed > 0: stored_message += f" (Armor absorbed {armor_absorbed})" for broken in broken_armor: stored_message += f"\n💔 {opponent['name']}'s {broken['emoji']} {broken['name']} broke!" # Check if opponent defeated if new_opponent_hp <= 0: stored_message += f"\n🏆 {current_player['name']} has defeated {opponent['name']}!" result_message = "Combat victory!" # Simple message, details in stored_message combat_over = True winner_id = current_player['id'] # Update opponent to dead state await db.update_player(opponent['id'], hp=0, is_dead=True) # Create corpse with opponent's inventory import json import time as time_module inventory = await db.get_inventory(opponent['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') }) logger.info(f"Creating player corpse (PvP death) for {opponent['name']} at {opponent['location_id']} with {len(inventory_items)} items") corpse_id = await db.create_player_corpse( player_name=opponent['name'], location_id=opponent['location_id'], items=json.dumps([{'item_id': i['item_id'], 'quantity': i['quantity']} for i in inventory]) ) logger.info(f"Successfully created player corpse (PvP death): ID={corpse_id}, player={opponent['name']}, location={opponent['location_id']}, items_count={len(inventory_items)}") # Clear opponent's inventory (items are now in corpse) await db.clear_inventory(opponent['id']) # Build corpse data for broadcast corpse_data = { "id": f"player_{corpse_id}", "type": "player", "name": f"{opponent['name']}'s Corpse", "emoji": "⚰️", "player_name": opponent['name'], "loot_count": len(inventory_items), "items": inventory_items, "timestamp": time_module.time() } # Update PvP statistics for both players await db.update_player_statistics(opponent['id'], pvp_deaths=1, pvp_combats_lost=1, pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True ) await db.update_player_statistics(current_player['id'], players_killed=1, pvp_combats_won=1, pvp_damage_dealt=damage, pvp_attacks_landed=1, increment=True ) # Broadcast to location that player died and corpse appeared logger.info(f"Broadcasting player_died (PvP death) to location {opponent['location_id']} for player {opponent['name']}") await manager.send_to_location( location_id=opponent['location_id'], message={ "type": "location_update", "data": { "message": f"{opponent['name']} was defeated by {current_player['name']} in PvP combat", "action": "player_died", "player_id": opponent['id'], "corpse": corpse_data }, "timestamp": datetime.utcnow().isoformat() } ) # End PvP combat await db.end_pvp_combat(pvp_combat['id']) else: # Combat continues - don't return detailed message, it's in stored_message result_message = "" # Empty message, frontend will show stored_message from polling # Update PvP statistics for attack await db.update_player_statistics(current_player['id'], pvp_damage_dealt=damage, pvp_attacks_landed=1, increment=True ) await db.update_player_statistics(opponent['id'], pvp_damage_taken=actual_damage, pvp_attacks_received=1, increment=True ) # Update combat state and switch turns # Add timestamp to make each action unique for duplicate detection updates = { 'turn': 'defender' if is_attacker else 'attacker', 'turn_started_at': time.time(), 'last_action': f"{stored_message}|{time.time()}" # Add timestamp for uniqueness } # No need to update HP in pvp_combat - we use player HP directly await db.update_pvp_combat(pvp_combat['id'], updates) await db.update_player_statistics(current_player['id'], damage_dealt=damage, increment=True) elif req.action == 'flee': # 50% chance to flee from PvP if random.random() < 0.5: result_message = f"You successfully fled from {opponent['name']}!" combat_over = True # Mark as fled, store last action with timestamp, and end combat flee_field = 'attacker_fled' if is_attacker else 'defender_fled' await db.update_pvp_combat(pvp_combat['id'], { flee_field: True, 'last_action': f"{current_player['name']} fled from combat!|{time.time()}" }) await db.end_pvp_combat(pvp_combat['id']) await db.update_player_statistics(current_player['id'], pvp_successful_flees=1, increment=True ) else: # Failed to flee, skip turn result_message = f"Failed to flee from {opponent['name']}!" await db.update_pvp_combat(pvp_combat['id'], { 'turn': 'defender' if is_attacker else 'attacker', 'turn_started_at': time.time(), 'last_action': f"{current_player['name']} tried to flee but failed!|{time.time()}" }) await db.update_player_statistics(current_player['id'], pvp_failed_flees=1, increment=True ) # Send WebSocket combat updates to both players # Get fresh PvP combat data updated_pvp = await db.get_pvp_combat_by_id(pvp_combat['id']) # Get fresh player data for HP updates fresh_attacker = await db.get_player_by_id(pvp_combat['attacker_character_id']) fresh_defender = await db.get_player_by_id(pvp_combat['defender_character_id']) # Send to both players with enriched data (like the API endpoint does) for player_id in [pvp_combat['attacker_character_id'], pvp_combat['defender_character_id']]: is_attacker = player_id == pvp_combat['attacker_character_id'] your_turn = (is_attacker and updated_pvp['turn'] == 'attacker') or \ (not is_attacker and updated_pvp['turn'] == 'defender') # Calculate time remaining import time time_elapsed = time.time() - updated_pvp['turn_started_at'] time_remaining = max(0, updated_pvp['turn_timeout_seconds'] - time_elapsed) # Build enriched pvp_combat object like the API does enriched_pvp = { "id": updated_pvp['id'], "attacker": { "id": fresh_attacker['id'], "username": fresh_attacker['name'], "level": fresh_attacker['level'], "hp": fresh_attacker['hp'], "max_hp": fresh_attacker['max_hp'] }, "defender": { "id": fresh_defender['id'], "username": fresh_defender['name'], "level": fresh_defender['level'], "hp": fresh_defender['hp'], "max_hp": fresh_defender['max_hp'] }, "is_attacker": is_attacker, "your_turn": your_turn, "current_turn": updated_pvp['turn'], "time_remaining": int(time_remaining), "location_id": updated_pvp['location_id'], "last_action": updated_pvp.get('last_action'), "combat_over": updated_pvp.get('attacker_fled', False) or updated_pvp.get('defender_fled', False) or \ fresh_attacker['hp'] <= 0 or fresh_defender['hp'] <= 0, "attacker_fled": updated_pvp.get('attacker_fled', False), "defender_fled": updated_pvp.get('defender_fled', False) } await manager.send_personal_message(player_id, { "type": "combat_update", "data": { "message": result_message if player_id == current_user['id'] else "", "log_entry": result_message if player_id == current_user['id'] else "", # Append to combat log "pvp_combat": enriched_pvp, "combat_over": combat_over, "winner_id": winner_id, "attacker_hp": fresh_attacker['hp'], "defender_hp": fresh_defender['hp'] }, "timestamp": datetime.utcnow().isoformat() }) return { "success": True, "message": result_message, "combat_over": combat_over, "winner_id": winner_id } @app.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} @app.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} {item_def.name} x{quantity}" } # ============================================================================ # Internal API Endpoints (for bot communication) # ============================================================================ async def verify_internal_key(authorization: str = Depends(security)): """Verify internal API key""" if authorization.credentials != API_INTERNAL_KEY: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid internal API key" ) return True @app.get("/api/internal/player/by_id/{player_id}", dependencies=[Depends(verify_internal_key)]) async def get_player_by_id(player_id: int): """Get player by unique database ID (for bot)""" player = await db.get_player_by_id(player_id) if not player: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Player not found" ) return player @app.get("/api/internal/player/{player_id}/combat", dependencies=[Depends(verify_internal_key)]) async def get_player_combat(player_id: int): """Get active combat for player (for bot)""" combat = await db.get_active_combat(player_id) return combat if combat else None @app.post("/api/internal/combat/create", dependencies=[Depends(verify_internal_key)]) async def create_combat(player_id: int, npc_id: str, npc_hp: int, npc_max_hp: int, location_id: str, from_wandering: bool = False): """Create new combat (for bot)""" combat = await db.create_combat(player_id, npc_id, npc_hp, npc_max_hp, location_id, from_wandering) return combat @app.patch("/api/internal/combat/{player_id}", dependencies=[Depends(verify_internal_key)]) async def update_combat(player_id: int, updates: dict): """Update combat state (for bot)""" success = await db.update_combat(player_id, updates) return {"success": success} @app.delete("/api/internal/combat/{player_id}", dependencies=[Depends(verify_internal_key)]) async def end_combat(player_id: int): """End combat (for bot)""" success = await db.end_combat(player_id) return {"success": success} @app.patch("/api/internal/player/{player_id}", dependencies=[Depends(verify_internal_key)]) async def update_player(player_id: int, updates: dict): """Update player fields (for bot)""" success = await db.update_player(player_id, updates) if not success: raise HTTPException(status_code=404, detail="Player not found") # Return updated player player = await db.get_player_by_id(player_id) return player @app.post("/api/internal/player/{player_id}/move", dependencies=[Depends(verify_internal_key)]) async def bot_move_player(player_id: int, direction: str): """Move player (for bot)""" success, message, new_location_id, stamina_cost, distance = await game_logic.move_player( player_id, direction, LOCATIONS ) # Track distance for bot players too if success: await db.update_player_statistics(player_id, distance_walked=distance, increment=True) return { "success": success, "message": message, "new_location_id": new_location_id } @app.get("/api/internal/player/{player_id}/inspect", dependencies=[Depends(verify_internal_key)]) async def bot_inspect_area(player_id: int): """Inspect area (for bot)""" player = await db.get_player_by_id(player_id) if not player: raise HTTPException(status_code=404, detail="Player not found") location = LOCATIONS.get(player['location_id']) if not location: raise HTTPException(status_code=404, detail="Location not found") message = await game_logic.inspect_area(player_id, location, {}) return {"success": True, "message": message} @app.post("/api/internal/player/{player_id}/interact", dependencies=[Depends(verify_internal_key)]) async def bot_interact(player_id: int, interactable_id: str, action_id: str): """Interact with object (for bot)""" player = await db.get_player_by_id(player_id) if not player: raise HTTPException(status_code=404, detail="Player not found") location = LOCATIONS.get(player['location_id']) if not location: raise HTTPException(status_code=404, detail="Location not found") result = await game_logic.interact_with_object( player_id, interactable_id, action_id, location, ITEMS_MANAGER ) return result @app.get("/api/internal/player/{player_id}/inventory", dependencies=[Depends(verify_internal_key)]) async def bot_get_inventory(player_id: int): """Get inventory (for bot)""" inventory = await db.get_inventory(player_id) # Enrich with item data (include all properties for bot compatibility) inventory_items = [] for inv_item in inventory: item = ITEMS_MANAGER.get_item(inv_item['item_id']) if item: inventory_items.append({ "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, "weight": getattr(item, 'weight', 0), "volume": getattr(item, 'volume', 0), "emoji": getattr(item, 'emoji', '❔'), "damage_min": getattr(item, 'damage_min', 0), "damage_max": getattr(item, 'damage_max', 0), "hp_restore": getattr(item, 'hp_restore', 0), "stamina_restore": getattr(item, 'stamina_restore', 0), "treats": getattr(item, 'treats', None) }) return {"success": True, "inventory": inventory_items} @app.post("/api/internal/player/{player_id}/use_item", dependencies=[Depends(verify_internal_key)]) async def bot_use_item(player_id: int, item_id: str): """Use item (for bot)""" result = await game_logic.use_item(player_id, item_id, ITEMS_MANAGER) return result @app.post("/api/internal/player/{player_id}/pickup", dependencies=[Depends(verify_internal_key)]) async def bot_pickup_item(player_id: int, item_id: str): """Pick up item (for bot)""" player = await db.get_player_by_id(player_id) if not player: raise HTTPException(status_code=404, detail="Player not found") result = await game_logic.pickup_item(player_id, item_id, player['location_id']) return result @app.post("/api/internal/player/{player_id}/drop_item", dependencies=[Depends(verify_internal_key)]) async def bot_drop_item(player_id: int, item_id: str, quantity: int = 1): """Drop item (for bot)""" player = await db.get_player_by_id(player_id) if not player: raise HTTPException(status_code=404, detail="Player not found") # Get the item from inventory inventory = await db.get_inventory(player_id) inv_item = next((i for i in inventory if i['item_id'] == item_id), None) if not inv_item or inv_item['quantity'] < quantity: return {"success": False, "message": "You don't have that item"} # Remove from inventory await db.remove_item_from_inventory(player_id, item_id, quantity) # Add to dropped items await db.add_dropped_item(player['location_id'], item_id, quantity) item = ITEMS_MANAGER.get_item(item_id) item_name = item.name if item else item_id return { "success": True, "message": f"You dropped {quantity}x {item_name}" } @app.post("/api/internal/player/{player_id}/equip", dependencies=[Depends(verify_internal_key)]) async def bot_equip_item(player_id: int, item_id: str): """Equip item (for bot)""" # Get item info item = ITEMS_MANAGER.get_item(item_id) if not item or not item.equippable: return {"success": False, "message": "This item cannot be equipped"} # Check inventory inventory = await db.get_inventory(player_id) inv_item = next((i for i in inventory if i['item_id'] == item_id), None) if not inv_item: return {"success": False, "message": "You don't have this item"} if inv_item['is_equipped']: return {"success": False, "message": "This item is already equipped"} # Unequip any item of the same type for inv in inventory: if inv['is_equipped']: existing_item = ITEMS_MANAGER.get_item(inv['item_id']) if existing_item and existing_item.type == item.type: await db.update_item_equipped_status(player_id, inv['item_id'], False) # Equip the new item await db.update_item_equipped_status(player_id, item_id, True) return {"success": True, "message": f"You equipped {item.name}"} @app.post("/api/internal/player/{player_id}/unequip", dependencies=[Depends(verify_internal_key)]) async def bot_unequip_item(player_id: int, item_id: str): """Unequip item (for bot)""" # Check inventory inventory = await db.get_inventory(player_id) inv_item = next((i for i in inventory if i['item_id'] == item_id), None) if not inv_item: return {"success": False, "message": "You don't have this item"} if not inv_item['is_equipped']: return {"success": False, "message": "This item is not equipped"} # Unequip the item await db.update_item_equipped_status(player_id, item_id, False) item = ITEMS_MANAGER.get_item(item_id) item_name = item.name if item else item_id return {"success": True, "message": f"You unequipped {item_name}"} # ============================================================================ # Dropped Items (Internal Bot API) # ============================================================================ @app.post("/api/internal/dropped-items", dependencies=[Depends(verify_internal_key)]) async def drop_item(item_id: str, quantity: int, location_id: str): """Drop an item to the world (for bot)""" success = await db.drop_item_to_world(item_id, quantity, location_id) return {"success": success} @app.get("/api/internal/dropped-items/{dropped_item_id}", dependencies=[Depends(verify_internal_key)]) async def get_dropped_item(dropped_item_id: int): """Get a specific dropped item (for bot)""" item = await db.get_dropped_item(dropped_item_id) if not item: raise HTTPException(status_code=404, detail="Dropped item not found") return item @app.get("/api/internal/location/{location_id}/dropped-items", dependencies=[Depends(verify_internal_key)]) async def get_dropped_items_in_location(location_id: str): """Get all dropped items in a location (for bot)""" items = await db.get_dropped_items_in_location(location_id) return items @app.patch("/api/internal/dropped-items/{dropped_item_id}", dependencies=[Depends(verify_internal_key)]) async def update_dropped_item(dropped_item_id: int, quantity: int): """Update dropped item quantity (for bot)""" success = await db.update_dropped_item(dropped_item_id, quantity) if not success: raise HTTPException(status_code=404, detail="Dropped item not found") return {"success": success} @app.delete("/api/internal/dropped-items/{dropped_item_id}", dependencies=[Depends(verify_internal_key)]) async def remove_dropped_item(dropped_item_id: int): """Remove a dropped item (for bot)""" success = await db.remove_dropped_item(dropped_item_id) return {"success": success} # ============================================================================ # Corpses (Internal Bot API) # ============================================================================ @app.post("/api/internal/corpses/player", dependencies=[Depends(verify_internal_key)]) async def create_player_corpse(player_name: str, location_id: str, items: str): """Create a player corpse (for bot)""" corpse_id = await db.create_player_corpse(player_name, location_id, items) return {"corpse_id": corpse_id} @app.get("/api/internal/corpses/player/{corpse_id}", dependencies=[Depends(verify_internal_key)]) async def get_player_corpse(corpse_id: int): """Get a player corpse (for bot)""" corpse = await db.get_player_corpse(corpse_id) if not corpse: raise HTTPException(status_code=404, detail="Player corpse not found") return corpse @app.patch("/api/internal/corpses/player/{corpse_id}", dependencies=[Depends(verify_internal_key)]) async def update_player_corpse(corpse_id: int, items: str): """Update player corpse items (for bot)""" success = await db.update_player_corpse(corpse_id, items) if not success: raise HTTPException(status_code=404, detail="Player corpse not found") return {"success": success} @app.delete("/api/internal/corpses/player/{corpse_id}", dependencies=[Depends(verify_internal_key)]) async def remove_player_corpse(corpse_id: int): """Remove a player corpse (for bot)""" success = await db.remove_player_corpse(corpse_id) return {"success": success} @app.post("/api/internal/corpses/npc", dependencies=[Depends(verify_internal_key)]) async def create_npc_corpse(npc_id: str, location_id: str, loot_remaining: str): """Create an NPC corpse (for bot)""" corpse_id = await db.create_npc_corpse(npc_id, location_id, loot_remaining) return {"corpse_id": corpse_id} @app.get("/api/internal/corpses/npc/{corpse_id}", dependencies=[Depends(verify_internal_key)]) async def get_npc_corpse(corpse_id: int): """Get an NPC corpse (for bot)""" corpse = await db.get_npc_corpse(corpse_id) if not corpse: raise HTTPException(status_code=404, detail="NPC corpse not found") return corpse @app.patch("/api/internal/corpses/npc/{corpse_id}", dependencies=[Depends(verify_internal_key)]) async def update_npc_corpse(corpse_id: int, loot_remaining: str): """Update NPC corpse loot (for bot)""" success = await db.update_npc_corpse(corpse_id, loot_remaining) if not success: raise HTTPException(status_code=404, detail="NPC corpse not found") return {"success": success} @app.delete("/api/internal/corpses/npc/{corpse_id}", dependencies=[Depends(verify_internal_key)]) async def remove_npc_corpse(corpse_id: int): """Remove an NPC corpse (for bot)""" success = await db.remove_npc_corpse(corpse_id) return {"success": success} # ============================================================================ # Wandering Enemies (Internal Bot API) # ============================================================================ @app.post("/api/internal/wandering-enemies", dependencies=[Depends(verify_internal_key)]) async def spawn_wandering_enemy(npc_id: str, location_id: str, current_hp: int, max_hp: int): """Spawn a wandering enemy (for bot)""" enemy_id = await db.spawn_wandering_enemy(npc_id, location_id, current_hp, max_hp) return {"enemy_id": enemy_id} @app.get("/api/internal/location/{location_id}/wandering-enemies", dependencies=[Depends(verify_internal_key)]) async def get_wandering_enemies_in_location(location_id: str): """Get all wandering enemies in a location (for bot)""" enemies = await db.get_wandering_enemies_in_location(location_id) return enemies @app.delete("/api/internal/wandering-enemies/{enemy_id}", dependencies=[Depends(verify_internal_key)]) async def remove_wandering_enemy(enemy_id: int): """Remove a wandering enemy (for bot)""" success = await db.remove_wandering_enemy(enemy_id) return {"success": success} @app.get("/api/internal/inventory/item/{item_db_id}", dependencies=[Depends(verify_internal_key)]) async def get_inventory_item(item_db_id: int): """Get a specific inventory item by database ID (for bot)""" item = await db.get_inventory_item(item_db_id) if not item: raise HTTPException(status_code=404, detail="Inventory item not found") return item # ============================================================================ # Cooldowns (Internal Bot API) # ============================================================================ @app.get("/api/internal/cooldown/{cooldown_key}", dependencies=[Depends(verify_internal_key)]) async def get_cooldown(cooldown_key: str): """Get remaining cooldown time in seconds (for bot)""" remaining = await db.get_cooldown(cooldown_key) return {"remaining_seconds": remaining} @app.post("/api/internal/cooldown/{cooldown_key}", dependencies=[Depends(verify_internal_key)]) async def set_cooldown(cooldown_key: str, duration_seconds: int = 600): """Set a cooldown (for bot)""" success = await db.set_cooldown(cooldown_key, duration_seconds) return {"success": success} # ============================================================================ # Corpse Lists (Internal Bot API) # ============================================================================ @app.get("/api/internal/location/{location_id}/corpses/player", dependencies=[Depends(verify_internal_key)]) async def get_player_corpses_in_location(location_id: str): """Get all player corpses in a location (for bot)""" corpses = await db.get_player_corpses_in_location(location_id) return corpses @app.get("/api/internal/location/{location_id}/corpses/npc", dependencies=[Depends(verify_internal_key)]) async def get_npc_corpses_in_location(location_id: str): """Get all NPC corpses in a location (for bot)""" corpses = await db.get_npc_corpses_in_location(location_id) return corpses # ============================================================================ # Image Cache (Internal Bot API) # ============================================================================ @app.get("/api/internal/image-cache/{image_path:path}", dependencies=[Depends(verify_internal_key)]) async def get_cached_image(image_path: str): """Get cached telegram file ID for an image (for bot)""" file_id = await db.get_cached_image(image_path) if not file_id: raise HTTPException(status_code=404, detail="Image not cached") return {"telegram_file_id": file_id} @app.post("/api/internal/image-cache", dependencies=[Depends(verify_internal_key)]) async def cache_image(image_path: str, telegram_file_id: str): """Cache a telegram file ID for an image (for bot)""" success = await db.cache_image(image_path, telegram_file_id) return {"success": success} # ============================================================================ # Status Effects (Internal Bot API) # ============================================================================ @app.get("/api/internal/player/{player_id}/status-effects", dependencies=[Depends(verify_internal_key)]) async def get_player_status_effects(player_id: int): """Get player status effects (for bot)""" effects = await db.get_player_status_effects(player_id) return effects # ============================================================================ # Statistics & Leaderboard Endpoints # ============================================================================ @app.get("/api/statistics/{player_id}") async def get_player_stats(player_id: int): """Get character statistics by character ID (public)""" stats = await db.get_player_statistics(player_id) if not stats: raise HTTPException(status_code=404, detail="Character statistics not found") player = await db.get_player_by_id(player_id) if not player: raise HTTPException(status_code=404, detail="Character not found") return { "player": { "id": player['id'], "name": player['name'], "level": player['level'] }, "statistics": stats } @app.get("/api/statistics/me") async def get_my_stats(current_user: dict = Depends(get_current_user)): """Get current user's statistics""" stats = await db.get_player_statistics(current_user['id']) return {"statistics": stats} @app.get("/api/leaderboard/{stat_name}") async def get_leaderboard_by_stat(stat_name: str, limit: int = 100): """ Get leaderboard for a specific statistic. Available stats: distance_walked, enemies_killed, damage_dealt, damage_taken, hp_restored, stamina_used, items_collected, deaths, etc. """ valid_stats = [ "distance_walked", "enemies_killed", "damage_dealt", "damage_taken", "hp_restored", "stamina_used", "stamina_restored", "items_collected", "items_dropped", "items_used", "deaths", "successful_flees", "failed_flees", "combats_initiated", "total_playtime" ] if stat_name not in valid_stats: raise HTTPException( status_code=400, detail=f"Invalid stat name. Valid stats: {', '.join(valid_stats)}" ) leaderboard = await db.get_leaderboard(stat_name, limit) return { "stat_name": stat_name, "leaderboard": leaderboard } # ============================================================================ # WebSocket Endpoint # ============================================================================ @app.websocket("/ws/game/{token}") async def websocket_endpoint(websocket: WebSocket, token: str): """ WebSocket endpoint for real-time game updates. Clients connect with their JWT token and receive live updates. """ character_id = None try: # Authenticate the token try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) # Support both character_id and old player_id character_id = payload.get("character_id") or payload.get("player_id") if character_id is None: await websocket.close(code=4001, reason="Invalid token") return player = await db.get_player_by_id(character_id) if not player: await websocket.close(code=4001, reason="Character not found") return username = player.get('name') or player.get('name', 'Unknown') except jwt.InvalidTokenError: await websocket.close(code=4001, reason="Invalid token") return # Connect the WebSocket await manager.connect(websocket, character_id, username) # Initialize player session in Redis if redis_manager: player = await db.get_player_by_id(character_id) await redis_manager.set_player_session(character_id, { "username": username, "location_id": player['location_id'], "hp": player['hp'], "max_hp": player['max_hp'], "stamina": player['stamina'], "max_stamina": player['max_stamina'], "level": player['level'], "xp": player['xp'], "websocket_connected": "true" }) # Add player to location registry await redis_manager.add_player_to_location(character_id, player['location_id']) # Send initial connection success message await manager.send_personal_message(character_id, { "type": "connected", "timestamp": datetime.utcnow().isoformat(), "message": "WebSocket connected successfully" }) # Send initial game state player = await db.get_player_by_id(character_id) location = LOCATIONS.get(player['location_id']) await manager.send_personal_message(character_id, { "type": "state_update", "data": { "player": { "hp": player['hp'], "max_hp": player['max_hp'], "stamina": player['stamina'], "max_stamina": player['max_stamina'], "location_id": player['location_id'], "level": player['level'], "xp": player['xp'] }, "location": { "id": location.id, "name": location.name } if location else None }, "timestamp": datetime.utcnow().isoformat() }) # Message loop - handle incoming messages while True: try: data = await websocket.receive_json() message_type = data.get("type") # Handle heartbeat if message_type == "heartbeat": await manager.send_personal_message(character_id, { "type": "heartbeat_ack", "timestamp": datetime.utcnow().isoformat() }) # Handle ping elif message_type == "ping": await manager.send_personal_message(character_id, { "type": "pong", "timestamp": datetime.utcnow().isoformat() }) # Future: Handle other message types (chat, emotes, etc.) except json.JSONDecodeError: await manager.send_personal_message(character_id, { "type": "error", "message": "Invalid JSON", "timestamp": datetime.utcnow().isoformat() }) except WebSocketDisconnect: if character_id: await manager.disconnect(character_id) except Exception as e: print(f"❌ WebSocket error for character {character_id}: {e}") if character_id: await manager.disconnect(character_id) # ============================================================================ # Health Check # ============================================================================ @app.get("/health") async def health_check(): """Health check endpoint""" return { "status": "healthy", "version": "2.0.0", "locations_loaded": len(LOCATIONS), "items_loaded": len(ITEMS_MANAGER.items) } if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)