Files
echoes-of-the-ash/api/main_pre_migration_backup.py
2025-11-27 16:27:01 +01:00

5574 lines
222 KiB
Python

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