5574 lines
222 KiB
Python
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)
|