This commit is contained in:
Joan
2025-11-27 16:27:01 +01:00
parent 33cc9586c2
commit 81f8912059
304 changed files with 56149 additions and 10122 deletions

89
api/analyze_endpoints.py Normal file
View File

@@ -0,0 +1,89 @@
"""
Complete migration script - Extracts all endpoints from main.py to routers
This preserves all functionality while creating a clean modular structure
"""
import re
import os
def read_file(path):
with open(path, 'r') as f:
return f.read()
def extract_section(content, start_marker, end_marker):
"""Extract a section between two markers"""
start = content.find(start_marker)
if start == -1:
return None
end = content.find(end_marker, start)
if end == -1:
end = len(content)
return content[start:end]
# Read original main.py
main_content = read_file('main.py')
# Find all endpoint definitions
endpoint_pattern = r'@app\.(get|post|put|delete|patch)\(["\']([^"\']+)["\']\)'
endpoints = re.findall(endpoint_pattern, main_content)
print(f"Found {len(endpoints)} endpoints in main.py:")
for method, path in endpoints[:20]: # Show first 20
print(f" {method.upper():6} {path}")
if len(endpoints) > 20:
print(f" ... and {len(endpoints) - 20} more")
# Group endpoints by category
categories = {
'auth': [],
'characters': [],
'game': [],
'combat': [],
'equipment': [],
'crafting': [],
'loot': [],
'admin': [],
'statistics': [],
'health': []
}
for method, path in endpoints:
if '/api/auth/' in path:
categories['auth'].append((method, path))
elif '/api/characters' in path:
categories['characters'].append((method, path))
elif '/api/game/combat' in path or '/api/game/pvp' in path:
categories['combat'].append((method, path))
elif '/api/game/equip' in path or '/api/game/unequip' in path or '/api/game/equipment' in path or '/api/game/repair' in path or '/api/game/repairable' in path or '/api/game/salvageable' in path:
categories['equipment'].append((method, path))
elif '/api/game/craft' in path or '/api/game/uncraft' in path or '/api/game/craftable' in path:
categories['crafting'].append((method, path))
elif '/api/game/corpse' in path or '/api/game/loot' in path:
categories['loot'].append((method, path))
elif '/api/internal/' in path:
categories['admin'].append((method, path))
elif '/api/statistics' in path or '/api/leaderboard' in path:
categories['statistics'].append((method, path))
elif '/health' in path:
categories['health'].append((method, path))
elif '/api/game/' in path:
categories['game'].append((method, path))
print("\n" + "="*60)
print("Endpoint Distribution:")
for cat, endpoints_list in categories.items():
if endpoints_list:
print(f" {cat:15}: {len(endpoints_list):2} endpoints")
print("\n" + "="*60)
print("\nNext steps:")
print("1. ✅ Auth router - already created")
print("2. ✅ Characters router - already created")
print("3. ⏳ Game routes router - needs creation (largest)")
print("4. ⏳ Combat router - needs creation")
print("5. ⏳ Equipment router - needs creation")
print("6. ⏳ Crafting router - needs creation")
print("7. ⏳ Loot router - needs creation")
print("8. ⏳ Admin router - needs creation")
print("9. ⏳ Statistics router - needs creation")
print("10. ⏳ Clean main.py - after all routers created")

View File

@@ -15,6 +15,7 @@ from api import database as db
from data.npcs import (
LOCATION_SPAWNS,
LOCATION_DANGER,
NPCS,
get_random_npc_for_location,
get_wandering_enemy_chance
)
@@ -51,10 +52,13 @@ def get_danger_level(location_id: str) -> int:
# BACKGROUND TASK: WANDERING ENEMY SPAWNER
# ============================================================================
async def spawn_manager_loop():
async def spawn_manager_loop(manager=None):
"""
Main spawn manager loop.
Runs continuously, checking spawn conditions every SPAWN_CHECK_INTERVAL seconds.
Args:
manager: WebSocket ConnectionManager for broadcasting spawn events
"""
logger.info("🎲 Spawn Manager started")
@@ -63,7 +67,26 @@ async def spawn_manager_loop():
await asyncio.sleep(SPAWN_CHECK_INTERVAL)
# Clean up expired enemies first
expired_enemies = await db.get_expired_wandering_enemies()
despawned_count = await db.cleanup_expired_wandering_enemies()
# Notify players in locations where enemies despawned
if manager and expired_enemies:
from datetime import datetime
for enemy in expired_enemies:
await manager.send_to_location(
location_id=enemy['location_id'],
message={
"type": "location_update",
"data": {
"message": f"A wandering enemy left the area",
"action": "enemy_despawned",
"enemy_id": enemy['id']
},
"timestamp": datetime.utcnow().isoformat()
}
)
if despawned_count > 0:
logger.info(f"🧹 Cleaned up {despawned_count} expired wandering enemies")
@@ -95,13 +118,43 @@ async def spawn_manager_loop():
# Spawn an enemy
npc_id = get_random_npc_for_location(location_id)
if npc_id:
await db.spawn_wandering_enemy(
enemy_data = await db.spawn_wandering_enemy(
npc_id=npc_id,
location_id=location_id,
lifetime_seconds=ENEMY_LIFETIME
)
if not enemy_data:
logger.error(f"Failed to spawn {npc_id} at {location_id}")
continue
spawned_count += 1
logger.info(f"👹 Spawned {npc_id} at {location_id} (current: {current_count + 1}/{max_enemies})")
# Notify players in this location
if manager:
from datetime import datetime
npc_def = NPCS.get(npc_id)
npc_name = npc_def.name if npc_def else npc_id.replace('_', ' ').title()
await manager.send_to_location(
location_id=location_id,
message={
"type": "location_update",
"data": {
"message": f"A {npc_name} appeared!",
"action": "enemy_spawned",
"npc_data": {
"id": enemy_data['id'],
"npc_id": npc_id,
"name": npc_name,
"type": "enemy",
"is_wandering": True,
"image_path": npc_def.image_path if npc_def else None
}
},
"timestamp": datetime.utcnow().isoformat()
}
)
if spawned_count > 0:
logger.info(f"✨ Spawn cycle complete: {spawned_count} enemies spawned")
@@ -116,8 +169,12 @@ async def spawn_manager_loop():
# BACKGROUND TASK: DROPPED ITEM DECAY
# ============================================================================
async def decay_dropped_items():
"""Periodically cleans up old dropped items."""
async def decay_dropped_items(manager=None):
"""Periodically cleans up old dropped items.
Args:
manager: WebSocket ConnectionManager for broadcasting decay events
"""
logger.info("🗑️ Item Decay task started")
while True:
@@ -130,8 +187,34 @@ async def decay_dropped_items():
# Set decay time to 1 hour (3600 seconds)
decay_seconds = 3600
timestamp_limit = int(time.time()) - decay_seconds
# Get expired items before removal to notify locations
expired_items = await db.get_expired_dropped_items(timestamp_limit)
items_removed = await db.remove_expired_dropped_items(timestamp_limit)
# Group expired items by location
if manager and expired_items:
from datetime import datetime
from collections import defaultdict
items_by_location = defaultdict(int)
for item in expired_items:
items_by_location[item['location_id']] += 1
# Notify each location
for location_id, count in items_by_location.items():
await manager.send_to_location(
location_id=location_id,
message={
"type": "location_update",
"data": {
"message": f"{count} dropped item(s) decayed",
"action": "items_decayed"
},
"timestamp": datetime.utcnow().isoformat()
}
)
elapsed = time.time() - start_time
if items_removed > 0:
logger.info(f"Decayed and removed {items_removed} old items in {elapsed:.2f}s")
@@ -145,8 +228,12 @@ async def decay_dropped_items():
# BACKGROUND TASK: STAMINA REGENERATION
# ============================================================================
async def regenerate_stamina():
"""Periodically regenerates stamina for all players."""
async def regenerate_stamina(manager=None):
"""Periodically regenerates stamina for all players.
Args:
manager: WebSocket ConnectionManager for notifying players
"""
logger.info("💪 Stamina Regeneration task started")
while True:
@@ -156,11 +243,28 @@ async def regenerate_stamina():
start_time = time.time()
logger.info("Running stamina regeneration...")
players_updated = await db.regenerate_all_players_stamina()
updated_players = await db.regenerate_all_players_stamina()
# Notify each player of their stamina regeneration
if manager and updated_players:
from datetime import datetime
for player in updated_players:
await manager.send_personal_message(
player['id'],
{
"type": "stamina_update",
"data": {
"stamina": int(player['new_stamina']),
"max_stamina": player['max_stamina'],
"message": "Stamina regenerated"
},
"timestamp": datetime.utcnow().isoformat()
}
)
elapsed = time.time() - start_time
if players_updated > 0:
logger.info(f"Regenerated stamina for {players_updated} players in {elapsed:.2f}s")
if updated_players:
logger.info(f"Regenerated stamina for {len(updated_players)} players in {elapsed:.2f}s")
# Alert if regeneration is taking too long (potential scaling issue)
if elapsed > 5.0:
@@ -193,17 +297,31 @@ async def check_combat_timers():
for combat in idle_combats:
try:
# Import combat logic from API
from api import game_logic
# Only process if it's player's turn (don't double-process)
if combat['turn'] != 'player':
continue
# Force end player's turn and let NPC attack
if combat['turn'] == 'player':
await db.update_combat(combat['player_id'], {
'turn': 'npc',
'turn_started_at': time.time()
})
# NPC attacks
await game_logic.npc_attack(combat['player_id'])
# Import required modules
from api import game_logic
from data.npcs import NPCS
# Get NPC definition
npc_def = NPCS.get(combat['npc_id'])
if not npc_def:
logger.warning(f"NPC definition not found: {combat['npc_id']}")
continue
# Import reduce_armor_durability from equipment router
from .routers.equipment import reduce_armor_durability
# NPC attacks due to timeout
logger.info(f"Player {combat['character_id']} combat timed out, NPC attacking...")
await game_logic.npc_attack(
combat['character_id'],
combat,
npc_def,
reduce_armor_durability
)
except Exception as e:
logger.error(f"Error processing idle combat: {e}")
@@ -221,12 +339,96 @@ async def check_combat_timers():
await asyncio.sleep(10)
# ============================================================================
# BACKGROUND TASK: INTERACTABLE COOLDOWN CLEANUP
# ============================================================================
async def cleanup_interactable_cooldowns(manager=None, world_locations=None):
"""
Cleans up expired interactable cooldowns and notifies players.
Args:
manager: WebSocket ConnectionManager for broadcasting cooldown expiry
world_locations: Dict of Location objects to map instance_id to location_id
"""
logger.info("⏳ Interactable Cooldown Cleanup task started")
while True:
try:
await asyncio.sleep(30) # Check every 30 seconds
# Get expired cooldowns before removal
expired_cooldowns = await db.get_expired_interactable_cooldowns()
removed_count = await db.remove_expired_interactable_cooldowns()
# Notify players in locations where cooldowns expired
if manager and expired_cooldowns and world_locations:
from datetime import datetime
from collections import defaultdict
# Map instance_id:action_id to location_id
cooldowns_by_location = defaultdict(list)
for cooldown in expired_cooldowns:
instance_id = cooldown['instance_id']
action_id = cooldown['action_id']
# Find which location has this interactable
for loc_id, location in world_locations.items():
for interactable in location.interactables:
if interactable.id == instance_id:
# Find action name
action_name = action_id
for action in interactable.actions:
if action.id == action_id:
action_name = action.label
break
cooldowns_by_location[loc_id].append({
'instance_id': instance_id,
'action_id': action_id,
'name': interactable.name,
'action_name': action_name
})
break
# Notify each location (only if players are there)
for location_id, cooldowns in cooldowns_by_location.items():
if not manager.has_players_in_location(location_id):
continue # Skip if no active players
for cooldown_info in cooldowns:
await manager.send_to_location(
location_id=location_id,
message={
"type": "interactable_ready",
"data": {
"instance_id": cooldown_info['instance_id'],
"action_id": cooldown_info['action_id'],
"message": f"{cooldown_info['action_name']} is ready on {cooldown_info['name']}"
},
"timestamp": datetime.utcnow().isoformat()
}
)
if removed_count > 0:
logger.info(f"🧹 Cleaned up {removed_count} expired interactable cooldowns")
except Exception as e:
logger.error(f"❌ Error in interactable cooldown cleanup: {e}", exc_info=True)
await asyncio.sleep(10)
# ============================================================================
# BACKGROUND TASK: CORPSE DECAY
# ============================================================================
async def decay_corpses():
"""Removes old corpses."""
async def decay_corpses(manager=None):
"""Removes old corpses.
Args:
manager: WebSocket ConnectionManager for broadcasting decay events
"""
logger.info("💀 Corpse Decay task started")
while True:
@@ -238,12 +440,44 @@ async def decay_corpses():
# Player corpses decay after 24 hours
player_corpse_limit = time.time() - (24 * 3600)
expired_player_corpses = await db.get_expired_player_corpses(player_corpse_limit)
player_corpses_removed = await db.remove_expired_player_corpses(player_corpse_limit)
# NPC corpses decay after 2 hours
npc_corpse_limit = time.time() - (2 * 3600)
expired_npc_corpses = await db.get_expired_npc_corpses(npc_corpse_limit)
npc_corpses_removed = await db.remove_expired_npc_corpses(npc_corpse_limit)
# Notify players in locations where corpses decayed
if manager:
from datetime import datetime
from collections import defaultdict
# Group corpses by location
corpses_by_location = defaultdict(lambda: {"player": 0, "npc": 0})
for corpse in expired_player_corpses:
corpses_by_location[corpse['location_id']]["player"] += 1
for corpse in expired_npc_corpses:
corpses_by_location[corpse['location_id']]["npc"] += 1
# Notify each location
for location_id, counts in corpses_by_location.items():
total = counts["player"] + counts["npc"]
corpse_type = "corpse" if total == 1 else "corpses"
await manager.send_to_location(
location_id=location_id,
message={
"type": "location_update",
"data": {
"message": f"{total} {corpse_type} decayed",
"action": "corpses_decayed"
},
"timestamp": datetime.utcnow().isoformat()
}
)
elapsed = time.time() - start_time
if player_corpses_removed > 0 or npc_corpses_removed > 0:
logger.info(f"Decayed {player_corpses_removed} player corpses and {npc_corpses_removed} NPC corpses in {elapsed:.2f}s")
@@ -257,10 +491,13 @@ async def decay_corpses():
# BACKGROUND TASK: STATUS EFFECTS PROCESSOR
# ============================================================================
async def process_status_effects():
async def process_status_effects(manager=None):
"""
Applies damage from persistent status effects.
Runs every 5 minutes to process status effect ticks.
Args:
manager: WebSocket ConnectionManager for notifying players
"""
logger.info("🩸 Status Effects Processor started")
@@ -321,10 +558,42 @@ async def process_status_effects():
# Remove status effects from dead player
await db.remove_all_status_effects(player_id)
# Notify player of death
if manager:
from datetime import datetime
await manager.send_personal_message(
player_id,
{
"type": "player_died",
"data": {
"hp": 0,
"is_dead": True,
"message": "You died from status effects"
},
"timestamp": datetime.utcnow().isoformat()
}
)
logger.info(f"Player {player['name']} (ID: {player_id}) died from status effects")
else:
# Apply damage
# Apply damage and notify player
await db.update_player(player_id, {'hp': new_hp})
if manager:
from datetime import datetime
await manager.send_personal_message(
player_id,
{
"type": "status_effect_damage",
"data": {
"hp": new_hp,
"max_hp": player['max_hp'],
"damage": total_damage,
"message": f"You took {total_damage} damage from status effects"
},
"timestamp": datetime.utcnow().isoformat()
}
)
except Exception as e:
logger.error(f"Error processing status effects for player {player_id}: {e}")
@@ -398,11 +667,15 @@ def release_background_tasks_lock():
_lock_file_handle = None
async def start_background_tasks():
async def start_background_tasks(manager=None, world_locations=None):
"""
Start all background tasks.
Called when the API starts up.
Only runs in ONE worker (the first one to acquire the lock).
Args:
manager: WebSocket ConnectionManager for broadcasting events
world_locations: Dict of Location objects for interactable mapping
"""
# Try to acquire lock - only one worker will succeed
if not acquire_background_tasks_lock():
@@ -413,12 +686,13 @@ async def start_background_tasks():
# Create tasks for all background jobs
tasks = [
asyncio.create_task(spawn_manager_loop()),
asyncio.create_task(decay_dropped_items()),
asyncio.create_task(regenerate_stamina()),
asyncio.create_task(spawn_manager_loop(manager)),
asyncio.create_task(decay_dropped_items(manager)),
asyncio.create_task(regenerate_stamina(manager)),
asyncio.create_task(check_combat_timers()),
asyncio.create_task(decay_corpses()),
asyncio.create_task(process_status_effects()),
asyncio.create_task(decay_corpses(manager)),
asyncio.create_task(process_status_effects(manager)),
# Note: Interactable cooldowns are handled client-side with server validation
]
logger.info(f"✅ Started {len(tasks)} background tasks")

0
api/core/__init__.py Normal file
View File

32
api/core/config.py Normal file
View File

@@ -0,0 +1,32 @@
"""
Configuration module for the API.
All environment variables and constants are defined here.
"""
import os
# 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")
# CORS Origins
CORS_ORIGINS = [
"https://staging.echoesoftheash.com",
"http://localhost:3000",
"http://localhost:5173"
]
# Database Configuration (imported from database module)
# DB settings are in database.py since they're tightly coupled with SQLAlchemy
# Image Directory
from pathlib import Path
IMAGES_DIR = Path(__file__).parent.parent.parent / "images"
# Game Constants
MOVEMENT_COOLDOWN = 5 # seconds
BASE_CARRYING_CAPACITY = 10.0 # kg
BASE_VOLUME_CAPACITY = 10.0 # liters

127
api/core/security.py Normal file
View File

@@ -0,0 +1,127 @@
"""
Security module for authentication and authorization.
Handles JWT tokens, password hashing, and auth dependencies.
"""
import jwt
import bcrypt
from datetime import datetime, timedelta
from typing import Dict, Any
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES, API_INTERNAL_KEY
from .. import database as db
security = HTTPBearer()
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
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"
)
def hash_password(password: str) -> str:
"""Hash a password using bcrypt"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def verify_password(password: str, password_hash: str) -> bool:
"""Verify a password against its hash"""
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
"""
Verify JWT token and return current character (requires character selection).
This is the main auth dependency for protected endpoints.
"""
try:
token = credentials.credentials
payload = decode_token(token)
# New system: account_id + character_id
account_id = payload.get("account_id")
if account_id is not None:
character_id = payload.get("character_id")
if character_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No character selected. Please select a character first."
)
player = await db.get_player_by_id(character_id)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Character not found"
)
# Verify character belongs to account
if player.get('account_id') != account_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Character does not belong to this account"
)
return player
# Old system fallback: player_id (for backward compatibility)
player_id = payload.get("player_id")
if player_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid token: no player or character ID"
)
player = await db.get_player_by_id(player_id)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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) as e:
if isinstance(e, HTTPException):
raise e
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
async def verify_internal_key(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Verify internal API key for bot endpoints"""
if credentials.credentials != API_INTERNAL_KEY:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid internal API key"
)
return True

209
api/core/websockets.py Normal file
View File

@@ -0,0 +1,209 @@
"""
WebSocket connection manager for real-time game updates.
Handles WebSocket connections and Redis pub/sub for cross-worker communication.
"""
from typing import Dict, Optional, List
from fastapi import WebSocket
import logging
logger = logging.getLogger(__name__)
class ConnectionManager:
"""
Manages WebSocket connections for real-time game updates.
Tracks active connections and provides methods for broadcasting messages.
Uses Redis pub/sub for cross-worker communication.
"""
def __init__(self):
# Maps player_id -> List of WebSocket connections (local to this worker only)
self.active_connections: Dict[int, List[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()
if player_id not in self.active_connections:
self.active_connections[player_id] = []
self.active_connections[player_id].append(websocket)
self.player_usernames[player_id] = username
# Subscribe to player's personal channel (only if first connection)
if len(self.active_connections[player_id]) == 1 and self.redis_manager:
await self.redis_manager.subscribe_to_channels([f"player:{player_id}"])
await self.redis_manager.mark_player_connected(player_id)
logger.info(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, websocket: WebSocket):
"""Remove a WebSocket connection."""
if player_id in self.active_connections:
username = self.player_usernames.get(player_id, "unknown")
if websocket in self.active_connections[player_id]:
self.active_connections[player_id].remove(websocket)
# If no more connections for this player, cleanup
if not self.active_connections[player_id]:
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)
logger.info(f"All WebSockets disconnected: {username} (player_id={player_id})")
else:
logger.info(f"WebSocket disconnected: {username} (player_id={player_id}). Remaining connections: {len(self.active_connections[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 connections."""
if player_id in self.active_connections:
connections = self.active_connections[player_id]
disconnected_sockets = []
for websocket in connections:
try:
logger.debug(f"Sending {message.get('type')} to player {player_id}")
await websocket.send_json(message)
except Exception as e:
logger.error(f"Failed to send message to player {player_id}: {e}")
disconnected_sockets.append(websocket)
# Cleanup failed sockets
for ws in disconnected_sockets:
await self.disconnect(player_id, ws)
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
for player_id in list(self.active_connections.keys()):
if player_id != exclude_player_id:
await self._send_direct(player_id, message)
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)
from .. import database as db
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
logger.info(f"Broadcasting to location {location_id}: {message.get('type')} (excluding player {exclude_player_id})")
sent_count = 0
for player in active_players:
player_id = player['id']
await self._send_direct(player_id, message)
sent_count += 1
logger.info(f"Sent {message.get('type')} to {sent_count} players")
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.
"""
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:
logger.error(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."""
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()

File diff suppressed because it is too large Load Diff

View File

@@ -33,15 +33,12 @@ async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[
if not new_location:
return False, "Destination not found", None, 0, 0
# Calculate total weight
# Calculate total weight and capacity
from api.items import items_manager as ITEMS_MANAGER
from api.services.helpers import calculate_player_capacity
inventory = await db.get_inventory(player_id)
total_weight = 0.0
for inv_item in inventory:
item = ITEMS_MANAGER.get_item(inv_item['item_id'])
if item:
total_weight += item.weight * inv_item['quantity']
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
# Calculate distance between locations (1 coordinate unit = 100 meters)
import math
@@ -53,9 +50,19 @@ async def move_player(player_id: int, direction: str, locations: Dict) -> Tuple[
# Calculate stamina cost: base from distance, adjusted by weight and agility
base_cost = max(1, round(distance / 50)) # 50m = 1 stamina
weight_penalty = int(total_weight / 10)
weight_penalty = int(current_weight / 10)
agility_reduction = int(player.get('agility', 5) / 3)
stamina_cost = max(1, base_cost + weight_penalty - agility_reduction)
# Add over-capacity penalty (50% extra stamina cost if over limit)
over_capacity_penalty = 0
if current_weight > max_weight or current_volume > max_volume:
weight_excess_ratio = max(0, (current_weight - max_weight) / max_weight) if max_weight > 0 else 0
volume_excess_ratio = max(0, (current_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)))
stamina_cost = max(1, base_cost + weight_penalty + over_capacity_penalty - agility_reduction)
# Check stamina
if player['stamina'] < stamina_cost:
@@ -130,10 +137,10 @@ async def interact_with_object(
if not player:
return {"success": False, "message": "Player not found"}
# Find the interactable
# Find the interactable (match by id or instance_id)
interactable = None
for obj in location.interactables:
if obj.id == interactable_id:
if obj.id == interactable_id or (hasattr(obj, 'instance_id') and obj.instance_id == interactable_id):
interactable = obj
break
@@ -157,13 +164,13 @@ async def interact_with_object(
"message": f"Not enough stamina. Need {action.stamina_cost}, have {player['stamina']}."
}
# Check cooldown
cooldown_expiry = await db.get_interactable_cooldown(interactable_id)
# Check cooldown for this specific action
cooldown_expiry = await db.get_interactable_cooldown(interactable_id, action_id)
if cooldown_expiry:
remaining = int(cooldown_expiry - time.time())
return {
"success": False,
"message": f"This object is still recovering. Wait {remaining} seconds."
"message": f"This action is still on cooldown. Wait {remaining} seconds."
}
# Deduct stamina
@@ -198,8 +205,10 @@ async def interact_with_object(
damage_taken = outcome.damage_taken
# Calculate current capacity
from api.main import calculate_player_capacity
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(player_id)
from api.services.helpers import calculate_player_capacity
from api.items import items_manager as ITEMS_MANAGER
inventory = await db.get_inventory(player_id)
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
# Add items to inventory (or drop if over capacity)
for item_id, quantity in outcome.items_reward.items():
@@ -233,11 +242,14 @@ async def interact_with_object(
current_volume += item.volume
else:
# Create unique_item and drop to ground
# Save base stats to unique_stats
base_stats = {k: int(v) if isinstance(v, (int, float)) else v for k, v in item.stats.items()} if item.stats else {}
unique_item_id = await db.create_unique_item(
item_id=item_id,
durability=item.durability,
max_durability=item.durability,
tier=getattr(item, 'tier', None)
tier=getattr(item, 'tier', None),
unique_stats=base_stats
)
await db.drop_item_to_world(item_id, 1, player['location_id'], unique_item_id=unique_item_id)
items_dropped.append(f"{emoji} {item_name}")
@@ -267,8 +279,8 @@ async def interact_with_object(
if new_hp <= 0:
await db.update_player(player_id, is_dead=True)
# Set cooldown (60 seconds default)
await db.set_interactable_cooldown(interactable_id, 60)
# Set cooldown for this specific action (60 seconds default)
await db.set_interactable_cooldown(interactable_id, action_id, 60)
# Build message
final_message = outcome.text
@@ -391,25 +403,12 @@ async def pickup_item(player_id: int, item_id: int, location_id: str, quantity:
pickup_qty = quantity
# Get player and calculate capacity
from api.services.helpers import calculate_player_capacity
player = await db.get_player_by_id(player_id)
inventory = await db.get_inventory(player_id)
# Calculate current weight and volume (including equipped bag capacity)
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:
inv_item_def = items_manager.get_item(inv_item['item_id']) if items_manager else None
if inv_item_def:
current_weight += inv_item_def.weight * inv_item['quantity']
current_volume += inv_item_def.volume * inv_item['quantity']
# Check for equipped bags/containers that increase capacity
if inv_item['is_equipped'] and inv_item_def.stats:
max_weight += inv_item_def.stats.get('weight_capacity', 0)
max_volume += inv_item_def.stats.get('volume_capacity', 0)
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, items_manager)
# Calculate weight and volume for items to pick up
item_weight = item_def.weight * pickup_qty
@@ -504,3 +503,146 @@ def calculate_status_damage(effects: list) -> int:
Total damage per tick
"""
return sum(effect.get('damage_per_tick', 0) for effect in effects)
# ============================================================================
# COMBAT UTILITIES
# ============================================================================
return message, player_defeated
def generate_npc_intent(npc_def, combat_state: dict) -> dict:
"""
Generate the NEXT intent for an NPC.
Returns a dict with intent type and details.
"""
# Default intent is attack
intent = {"type": "attack", "value": 0}
# Logic could be more complex based on NPC type, HP, etc.
roll = random.random()
# 20% chance to defend if HP < 50%
if (combat_state['npc_hp'] / combat_state['npc_max_hp'] < 0.5) and roll < 0.2:
intent = {"type": "defend", "value": 0}
# 15% chance for special attack (if defined, otherwise strong attack)
elif roll < 0.35:
intent = {"type": "special", "value": 0}
else:
intent = {"type": "attack", "value": 0}
return intent
async def npc_attack(player_id: int, combat: dict, npc_def, reduce_armor_func) -> Tuple[str, bool]:
"""
Execute NPC turn based on PREVIOUS intent, then generate NEXT intent.
"""
player = await db.get_player_by_id(player_id)
if not player:
return "Player not found", True
# Parse current intent (stored in DB as string or JSON, assuming simple string for now or we parse it)
# For now, let's assume simple string "attack", "defend", "special" stored in npc_intent
# If we want more complex data, we should use JSON, but the migration added VARCHAR.
# Let's stick to simple string for the column, but we can store "type:value" if needed.
current_intent_str = combat.get('npc_intent', 'attack')
# Handle legacy/null
if not current_intent_str:
current_intent_str = 'attack'
intent_type = current_intent_str
message = ""
actual_damage = 0
# EXECUTE INTENT
if intent_type == 'defend':
# NPC defends - maybe heals or takes less damage next turn?
# For simplicity: Heals 5% HP
heal_amount = int(combat['npc_max_hp'] * 0.05)
new_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + heal_amount)
await db.update_combat(player_id, {'npc_hp': new_npc_hp})
message = f"{npc_def.name} defends and recovers {heal_amount} HP!"
elif intent_type == 'special':
# Strong attack (1.5x damage)
npc_damage = int(random.randint(npc_def.damage_min, npc_def.damage_max) * 1.5)
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage)
message = f"{npc_def.name} uses a SPECIAL ATTACK for {npc_damage} damage!"
if armor_absorbed > 0:
message += f" (Armor absorbed {armor_absorbed})"
if broken_armor:
for armor in broken_armor:
message += f"\n💔 Your {armor['emoji']} {armor['name']} broke!"
await db.update_player(player_id, hp=new_player_hp)
else: # Default 'attack'
npc_damage = random.randint(npc_def.damage_min, npc_def.damage_max)
# Enrage bonus if NPC is below 30% HP
if combat['npc_hp'] / combat['npc_max_hp'] < 0.3:
npc_damage = int(npc_damage * 1.5)
message = f"{npc_def.name} is ENRAGED! "
else:
message = ""
armor_absorbed, broken_armor = await reduce_armor_func(player_id, npc_damage)
actual_damage = max(1, npc_damage - armor_absorbed)
new_player_hp = max(0, player['hp'] - actual_damage)
message += f"{npc_def.name} attacks for {npc_damage} damage!"
if armor_absorbed > 0:
message += f" (Armor absorbed {armor_absorbed})"
if broken_armor:
for armor in broken_armor:
message += f"\n💔 Your {armor['emoji']} {armor['name']} broke!"
await db.update_player(player_id, hp=new_player_hp)
# GENERATE NEXT INTENT
# We need to update the combat state with the new HP values first to make good decisions
# But we can just use the values we calculated.
# Check if player defeated
player_defeated = False
if player['hp'] - actual_damage <= 0 and intent_type != 'defend': # Check HP after damage
# Re-fetch to be sure or just trust calculation
if new_player_hp <= 0:
message += "\nYou have been defeated!"
player_defeated = True
await db.update_player(player_id, hp=0, is_dead=True)
await db.update_player_statistics(player_id, deaths=1, damage_taken=actual_damage, increment=True)
await db.end_combat(player_id)
return message, player_defeated
if not player_defeated:
if actual_damage > 0:
await db.update_player_statistics(player_id, damage_taken=actual_damage, increment=True)
# Generate NEXT intent
# We need the updated NPC HP for the logic
current_npc_hp = combat['npc_hp']
if intent_type == 'defend':
current_npc_hp = min(combat['npc_max_hp'], combat['npc_hp'] + int(combat['npc_max_hp'] * 0.05))
temp_combat_state = combat.copy()
temp_combat_state['npc_hp'] = current_npc_hp
next_intent = generate_npc_intent(npc_def, temp_combat_state)
# Update combat with new intent and turn
await db.update_combat(player_id, {
'turn': 'player',
'turn_started_at': time.time(),
'npc_intent': next_intent['type']
})
return message, player_defeated

169
api/generate_routers.py Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""
Automated endpoint extraction and router generation script.
This script reads main.py and generates complete router files.
"""
import re
import os
from pathlib import Path
def extract_endpoint_function(content, endpoint_decorator):
"""
Extract the complete function code for an endpoint.
Finds the decorator and extracts everything until the next @app decorator or end of file.
"""
# Find the decorator position
start = content.find(endpoint_decorator)
if start == -1:
return None
# Find the next @app decorator or end of imports section
next_endpoint = content.find('\n@app.', start + len(endpoint_decorator))
next_section = content.find('\n# ===', start + len(endpoint_decorator))
# Use whichever comes first
if next_endpoint == -1 and next_section == -1:
end = len(content)
elif next_endpoint == -1:
end = next_section
elif next_section == -1:
end = next_endpoint
else:
end = min(next_endpoint, next_section)
return content[start:end].strip()
def generate_router_file(router_name, endpoints, has_models=False):
"""Generate a complete router file with all endpoints"""
# Base imports
imports = f'''"""
{router_name.replace('_', ' ').title()} router.
Auto-generated from main.py migration.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import HTTPAuthorizationCredentials
from typing import Optional, Dict, Any
from datetime import datetime
import random
import json
import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
from ..core.websockets import manager
logger = logging.getLogger(__name__)
# These will be injected by main.py
LOCATIONS = None
ITEMS_MANAGER = None
WORLD = None
def init_router_dependencies(locations, items_manager, world):
"""Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
router = APIRouter(tags=["{router_name}"])
'''
# Add endpoints
router_content = imports + "\n\n# Endpoints\n\n" + "\n\n\n".join(endpoints)
return router_content
def main():
# Read main.py
main_path = Path('main.py')
if not main_path.exists():
print("ERROR: main.py not found!")
return
content = main_path.read_text()
# Define endpoint groups
endpoint_groups = {
'game_routes': [
'@app.get("/api/game/state")',
'@app.get("/api/game/profile")',
'@app.post("/api/game/spend_point")',
'@app.get("/api/game/location")',
'@app.post("/api/game/move")',
'@app.post("/api/game/inspect")',
'@app.post("/api/game/interact")',
'@app.post("/api/game/use_item")',
'@app.post("/api/game/pickup")',
'@app.get("/api/game/inventory")',
'@app.post("/api/game/item/drop")',
],
'equipment': [
'@app.post("/api/game/equip")',
'@app.post("/api/game/unequip")',
'@app.get("/api/game/equipment")',
'@app.post("/api/game/repair_item")',
'@app.get("/api/game/repairable")',
'@app.get("/api/game/salvageable")',
],
'crafting': [
'@app.get("/api/game/craftable")',
'@app.post("/api/game/craft_item")',
'@app.post("/api/game/uncraft_item")',
],
'loot': [
'@app.get("/api/game/corpse/{corpse_id}")',
'@app.post("/api/game/loot_corpse")',
],
'combat': [
'@app.get("/api/game/combat")',
'@app.post("/api/game/combat/initiate")',
'@app.post("/api/game/combat/action")',
'@app.post("/api/game/pvp/initiate")',
'@app.get("/api/game/pvp/status")',
'@app.post("/api/game/pvp/acknowledge")',
'@app.post("/api/game/pvp/action")',
],
'statistics': [
'@app.get("/api/statistics/{player_id}")',
'@app.get("/api/statistics/me")',
'@app.get("/api/leaderboard/{stat_name}")',
],
}
# Process each group
for router_name, decorators in endpoint_groups.items():
print(f"\nProcessing {router_name}...")
endpoints = []
for decorator in decorators:
func_code = extract_endpoint_function(content, decorator)
if func_code:
# Replace @app with @router
func_code = func_code.replace('@app.', '@router.')
endpoints.append(func_code)
print(f" ✓ Extracted: {decorator}")
else:
print(f" ✗ Not found: {decorator}")
if endpoints:
router_content = generate_router_file(router_name, endpoints)
output_path = Path(f'routers/{router_name}.py')
output_path.write_text(router_content)
print(f" ✅ Created routers/{router_name}.py with {len(endpoints)} endpoints")
else:
print(f" ⚠️ No endpoints found for {router_name}")
print("\n" + "="*60)
print("Router generation complete!")
print("Next step: Create new streamlined main.py")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

170
api/main_new.py Normal file
View File

@@ -0,0 +1,170 @@
"""
Echoes of the Ashes - Main FastAPI Application
Streamlined with modular routers for maintainability
"""
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from pathlib import Path
import logging
# Import core modules
from .core.config import CORS_ORIGINS, IMAGES_DIR
from .core.websockets import manager
from .core.security import get_current_user
# Import database and game data
from . import database as db
from .world_loader import load_world, World, Location
from .items import ItemsManager
from . import background_tasks
from .redis_manager import redis_manager
# Import routers
from .routers import auth, characters
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Load game data
print("🔄 Loading game world...")
WORLD: World = load_world()
LOCATIONS = WORLD.locations
ITEMS_MANAGER = ItemsManager()
print(f"✅ Game world ready: {len(LOCATIONS)} locations, {len(ITEMS_MANAGER.items)} items")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager for startup/shutdown"""
# 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")
# Initialize FastAPI app
app = FastAPI(
title="Echoes of the Ashes API",
version="2.0.0",
description="Post-apocalyptic survival RPG API",
lifespan=lifespan
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount static files for 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}")
# Include routers
app.include_router(auth.router)
app.include_router(characters.router)
# TODO: Add remaining routers as they are created:
# app.include_router(game_routes.router)
# app.include_router(combat.router)
# app.include_router(equipment.router)
# app.include_router(crafting.router)
# app.include_router(loot.router)
# app.include_router(admin.router)
# app.include_router(statistics.router)
@app.get("/health")
async def health_check():
"""Health check endpoint for load balancers"""
return {"status": "ok"}
@app.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
current_user: dict = Depends(get_current_user)
):
"""WebSocket endpoint for real-time game updates"""
player_id = current_user['id']
username = current_user['name']
await manager.connect(websocket, player_id, username)
# Get player's location and register in Redis
location_id = current_user.get('location_id')
if location_id and redis_manager:
await redis_manager.add_player_to_location(location_id, player_id)
# Store session data
await redis_manager.update_player_session(player_id, {
'username': username,
'location_id': location_id,
'level': current_user.get('level', 1),
'websocket_connected': 'true'
})
try:
while True:
# Keep connection alive
data = await websocket.receive_text()
# You can handle client messages here if needed
logger.debug(f"Received from {username}: {data}")
except WebSocketDisconnect:
await manager.disconnect(player_id)
# Remove from location registry
if location_id and redis_manager:
await redis_manager.remove_player_from_location(location_id, player_id)
print(f"WebSocket disconnected: {username}")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

90
api/migrate_main.py Normal file
View File

@@ -0,0 +1,90 @@
"""
Script to help migrate main.py endpoints to router files.
This script analyzes endpoint patterns and generates router code.
"""
# Endpoint grouping patterns
ROUTER_GROUPS = {
"game_routes": [
"/api/game/state",
"/api/game/profile",
"/api/game/spend_point",
"/api/game/location",
"/api/game/move",
"/api/game/inspect",
"/api/game/interact",
"/api/game/use_item",
"/api/game/pickup",
"/api/game/inventory",
"/api/game/item/drop"
],
"equipment": [
"/api/game/equip",
"/api/game/unequip",
"/api/game/equipment",
"/api/game/repair_item",
"/api/game/repairable",
"/api/game/salvageable"
],
"crafting": [
"/api/game/craftable",
"/api/game/craft_item",
"/api/game/uncraft_item"
],
"loot": [
"/api/game/corpse/{corpse_id}",
"/api/game/loot_corpse"
],
"combat": [
"/api/game/combat",
"/api/game/combat/initiate",
"/api/game/combat/action",
"/api/game/pvp/initiate",
"/api/game/pvp/status",
"/api/game/pvp/acknowledge",
"/api/game/pvp/action"
],
"admin": [
"/api/internal/player/by_id/{player_id}",
"/api/internal/player/{player_id}/combat",
"/api/internal/combat/create",
"/api/internal/combat/{player_id}",
"/api/internal/player/{player_id}",
"/api/internal/player/{player_id}/move",
"/api/internal/player/{player_id}/inspect",
"/api/internal/player/{player_id}/interact",
"/api/internal/player/{player_id}/inventory",
"/api/internal/player/{player_id}/use_item",
"/api/internal/player/{player_id}/pickup",
"/api/internal/player/{player_id}/drop_item",
"/api/internal/player/{player_id}/equip",
"/api/internal/player/{player_id}/unequip",
"/api/internal/dropped-items",
"/api/internal/dropped-items/{dropped_item_id}",
"/api/internal/location/{location_id}/dropped-items",
"/api/internal/corpses/player",
"/api/internal/corpses/player/{corpse_id}",
"/api/internal/corpses/npc",
"/api/internal/corpses/npc/{corpse_id}",
"/api/internal/wandering-enemies",
"/api/internal/location/{location_id}/wandering-enemies",
"/api/internal/wandering-enemies/{enemy_id}",
"/api/internal/inventory/item/{item_db_id}",
"/api/internal/cooldown/{cooldown_key}",
"/api/internal/location/{location_id}/corpses/player",
"/api/internal/location/{location_id}/corpses/npc",
"/api/internal/image-cache/{image_path:path}",
"/api/internal/image-cache",
"/api/internal/player/{player_id}/status-effects"
],
"statistics": [
"/api/statistics/{player_id}",
"/api/statistics/me",
"/api/leaderboard/{stat_name}"
]
}
print("Router migration patterns defined")
print(f"Total routes to migrate: {sum(len(v) for v in ROUTER_GROUPS.values())}")
for router_name, routes in ROUTER_GROUPS.items():
print(f" - {router_name}: {len(routes)} routes")

View File

@@ -0,0 +1,17 @@
import asyncio
from sqlalchemy import text
from api.database import engine
async def migrate():
print("Starting migration: Adding npc_intent column to active_combats table...")
async with engine.begin() as conn:
try:
# Check if column exists first to avoid errors
# This is a simple check, might vary based on exact postgres version but usually works
await conn.execute(text("ALTER TABLE active_combats ADD COLUMN IF NOT EXISTS npc_intent VARCHAR DEFAULT 'attack'"))
print("Migration successful: Added npc_intent column.")
except Exception as e:
print(f"Migration failed: {e}")
if __name__ == "__main__":
asyncio.run(migrate())

455
api/redis_manager.py Normal file
View File

@@ -0,0 +1,455 @@
"""
Redis Manager for Echoes of the Ashes
Handles Redis pub/sub for cross-worker communication and caching for performance.
Key Features:
- Pub/Sub channels for location broadcasts and personal messages
- Player session caching (location, HP, stats)
- Location player registry (Set of character IDs per location)
- Inventory caching with aggressive invalidation
- Combat state caching
- Disconnected player tracking
"""
import asyncio
import json
import time
import uuid
from typing import Dict, List, Optional, Set, Any, Callable
import redis.asyncio as redis
from redis.asyncio.client import PubSub
class RedisManager:
"""Manages Redis connections, pub/sub, and caching."""
def __init__(self, redis_url: str = "redis://echoes_of_the_ashes_redis:6379"):
self.redis_url = redis_url
self.redis_client: Optional[redis.Redis] = None
self.pubsub: Optional[PubSub] = None
self.worker_id = str(uuid.uuid4())[:8] # Unique worker identifier
self.subscribed_channels: Set[str] = set()
self.message_handlers: Dict[str, Callable] = {}
self._listener_task: Optional[asyncio.Task] = None
async def connect(self):
"""Establish connection to Redis."""
self.redis_client = redis.from_url(
self.redis_url,
encoding="utf-8",
decode_responses=True,
max_connections=50
)
self.pubsub = self.redis_client.pubsub()
print(f"✅ Redis connected (Worker: {self.worker_id})")
async def disconnect(self):
"""Close Redis connection and cleanup."""
if self._listener_task:
self._listener_task.cancel()
try:
await self._listener_task
except asyncio.CancelledError:
pass
if self.pubsub:
await self.pubsub.unsubscribe()
await self.pubsub.close()
if self.redis_client:
await self.redis_client.close()
print(f"🔌 Redis disconnected (Worker: {self.worker_id})")
# ==================== PUB/SUB ====================
async def subscribe_to_channels(self, channels: List[str]):
"""Subscribe to multiple channels."""
if not self.pubsub:
raise RuntimeError("Redis pubsub not initialized")
for channel in channels:
if channel not in self.subscribed_channels:
await self.pubsub.subscribe(channel)
self.subscribed_channels.add(channel)
print(f"📡 Worker {self.worker_id} subscribed to {len(channels)} channels")
async def unsubscribe_from_channel(self, channel: str):
"""Unsubscribe from a specific channel."""
if self.pubsub and channel in self.subscribed_channels:
await self.pubsub.unsubscribe(channel)
self.subscribed_channels.discard(channel)
async def publish_to_channel(self, channel: str, message: Dict[str, Any]):
"""Publish a message to a Redis channel."""
if not self.redis_client:
raise RuntimeError("Redis client not initialized")
message_data = {
"worker_id": self.worker_id,
"timestamp": time.time(),
**message
}
await self.redis_client.publish(channel, json.dumps(message_data))
async def publish_to_location(self, location_id: str, message: Dict[str, Any]):
"""Publish a message to all players in a location."""
await self.publish_to_channel(f"location:{location_id}", message)
async def publish_to_player(self, character_id: int, message: Dict[str, Any]):
"""Publish a personal message to a specific player."""
await self.publish_to_channel(f"player:{character_id}", message)
async def publish_global_broadcast(self, message: Dict[str, Any]):
"""Publish a message to all connected players."""
await self.publish_to_channel("game:broadcast", message)
async def listen_for_messages(self, handler: Callable):
"""Listen for Redis pub/sub messages and route to handler.
Args:
handler: Async function that receives (channel, message_data)
"""
if not self.pubsub:
raise RuntimeError("Redis pubsub not initialized")
print(f"👂 Worker {self.worker_id} listening for Redis messages...")
async for message in self.pubsub.listen():
if message["type"] == "message":
channel = message["channel"]
try:
data = json.loads(message["data"])
# Don't process messages from this same worker (already handled locally)
if data.get("worker_id") == self.worker_id:
continue
# Route to handler
await handler(channel, data)
except json.JSONDecodeError:
print(f"⚠️ Invalid JSON in Redis message: {message['data']}")
except Exception as e:
print(f"❌ Error handling Redis message: {e}")
def start_listener(self, handler: Callable):
"""Start background task to listen for Redis messages."""
self._listener_task = asyncio.create_task(self.listen_for_messages(handler))
# ==================== PLAYER SESSIONS ====================
async def set_player_session(self, character_id: int, session_data: Dict[str, Any], ttl: int = 1800):
"""Cache player session data (30 min TTL by default).
Args:
character_id: Player's character ID
session_data: Dict with keys like 'location_id', 'hp', 'level', etc.
ttl: Time-to-live in seconds (default 30 minutes)
"""
key = f"player:{character_id}:session"
# Convert all values to strings for Redis hash
string_data = {k: str(v) for k, v in session_data.items()}
await self.redis_client.hset(key, mapping=string_data)
await self.redis_client.expire(key, ttl)
async def get_player_session(self, character_id: int) -> Optional[Dict[str, Any]]:
"""Retrieve cached player session data."""
key = f"player:{character_id}:session"
data = await self.redis_client.hgetall(key)
if not data:
return None
# Note: Values come back as strings, convert as needed
return data
async def update_player_session_field(self, character_id: int, field: str, value: Any):
"""Update a single field in player session (e.g., HP, location)."""
key = f"player:{character_id}:session"
await self.redis_client.hset(key, field, str(value))
# Refresh TTL
await self.redis_client.expire(key, 1800)
async def delete_player_session(self, character_id: int):
"""Delete player session from cache (force reload from DB)."""
key = f"player:{character_id}:session"
await self.redis_client.delete(key)
# ==================== LOCATION PLAYER REGISTRY ====================
async def add_player_to_location(self, character_id: int, location_id: str):
"""Add player to location's player set."""
key = f"location:{location_id}:players"
await self.redis_client.sadd(key, character_id)
async def remove_player_from_location(self, character_id: int, location_id: str):
"""Remove player from location's player set."""
key = f"location:{location_id}:players"
await self.redis_client.srem(key, character_id)
async def move_player_between_locations(self, character_id: int, from_location: str, to_location: str):
"""Atomically move player from one location to another."""
pipe = self.redis_client.pipeline()
pipe.srem(f"location:{from_location}:players", character_id)
pipe.sadd(f"location:{to_location}:players", character_id)
await pipe.execute()
async def get_players_in_location(self, location_id: str) -> List[int]:
"""Get list of all player IDs in a location."""
key = f"location:{location_id}:players"
members = await self.redis_client.smembers(key)
return [int(m) for m in members]
async def is_player_in_location(self, character_id: int, location_id: str) -> bool:
"""Check if player is in a specific location."""
key = f"location:{location_id}:players"
return await self.redis_client.sismember(key, character_id)
# ==================== INVENTORY CACHING ====================
async def cache_inventory(self, character_id: int, inventory_data: List[Dict], ttl: int = 600):
"""Cache player inventory (10 min TTL).
Args:
character_id: Player's character ID
inventory_data: List of inventory items
ttl: Time-to-live in seconds (default 10 minutes)
"""
key = f"player:{character_id}:inventory"
await self.redis_client.setex(key, ttl, json.dumps(inventory_data))
async def get_cached_inventory(self, character_id: int) -> Optional[List[Dict]]:
"""Retrieve cached inventory."""
key = f"player:{character_id}:inventory"
data = await self.redis_client.get(key)
if not data:
return None
return json.loads(data)
async def invalidate_inventory(self, character_id: int):
"""Delete inventory cache (force reload from DB)."""
key = f"player:{character_id}:inventory"
await self.redis_client.delete(key)
# ==================== COMBAT STATE CACHING ====================
async def cache_combat_state(self, character_id: int, combat_data: Dict[str, Any]):
"""Cache active combat state (no expiration, deleted when combat ends).
Args:
character_id: Player's character ID
combat_data: Combat state dict (npc_id, npc_hp, turn, etc.)
"""
key = f"player:{character_id}:combat"
# Convert to strings for hash
string_data = {k: str(v) for k, v in combat_data.items()}
await self.redis_client.hset(key, mapping=string_data)
async def get_combat_state(self, character_id: int) -> Optional[Dict[str, Any]]:
"""Retrieve cached combat state."""
key = f"player:{character_id}:combat"
data = await self.redis_client.hgetall(key)
if not data:
return None
return data
async def update_combat_field(self, character_id: int, field: str, value: Any):
"""Update single field in combat state (e.g., npc_hp, turn)."""
key = f"player:{character_id}:combat"
await self.redis_client.hset(key, field, str(value))
async def delete_combat_state(self, character_id: int):
"""Delete combat state (combat ended)."""
key = f"player:{character_id}:combat"
await self.redis_client.delete(key)
# ==================== DROPPED ITEMS ====================
async def add_dropped_item(self, location_id: str, item_data: Dict[str, Any], ttl: int = 3600):
"""Add a dropped item to location's list (1 hour TTL).
Args:
location_id: Location where item was dropped
item_data: Item details (item_id, unique_item_id, timestamp, etc.)
ttl: Time-to-live in seconds (default 1 hour)
"""
key = f"location:{location_id}:dropped_items"
# Use a list to store dropped items
await self.redis_client.rpush(key, json.dumps(item_data))
await self.redis_client.expire(key, ttl)
async def get_dropped_items(self, location_id: str) -> List[Dict[str, Any]]:
"""Get all dropped items in a location."""
key = f"location:{location_id}:dropped_items"
items = await self.redis_client.lrange(key, 0, -1)
return [json.loads(item) for item in items]
async def remove_dropped_item(self, location_id: str, item_data: Dict[str, Any]):
"""Remove a specific dropped item (when picked up)."""
key = f"location:{location_id}:dropped_items"
await self.redis_client.lrem(key, 1, json.dumps(item_data))
# ==================== WORKER REGISTRY ====================
async def register_worker(self):
"""Register this worker as active."""
await self.redis_client.sadd("active_workers", self.worker_id)
# Set heartbeat timestamp
await self.redis_client.hset(
f"worker:{self.worker_id}:heartbeat",
mapping={
"timestamp": str(time.time()),
"status": "online"
}
)
async def unregister_worker(self):
"""Unregister this worker."""
await self.redis_client.srem("active_workers", self.worker_id)
await self.redis_client.delete(f"worker:{self.worker_id}:heartbeat")
async def get_active_workers(self) -> List[str]:
"""Get list of all active worker IDs."""
members = await self.redis_client.smembers("active_workers")
return list(members)
async def update_heartbeat(self):
"""Update worker heartbeat timestamp."""
await self.redis_client.hset(
f"worker:{self.worker_id}:heartbeat",
"timestamp",
str(time.time())
)
# ==================== DISTRIBUTED LOCKS ====================
async def acquire_lock(self, lock_name: str, ttl: int = 60) -> bool:
"""Acquire a distributed lock for background tasks.
Args:
lock_name: Name of the lock (e.g., "spawn_task", "regen_task")
ttl: Lock expiration in seconds (default 60s)
Returns:
True if lock acquired, False if already held by another worker
"""
key = f"lock:{lock_name}"
# SET key value NX EX ttl (only set if not exists, with expiration)
result = await self.redis_client.set(
key,
self.worker_id,
nx=True,
ex=ttl
)
return result is not None
async def release_lock(self, lock_name: str):
"""Release a distributed lock."""
key = f"lock:{lock_name}"
# Only delete if this worker owns the lock
lock_owner = await self.redis_client.get(key)
if lock_owner == self.worker_id:
await self.redis_client.delete(key)
# ==================== DISCONNECTED PLAYERS ====================
async def mark_player_disconnected(self, character_id: int):
"""Mark player as disconnected (but keep in location registry)."""
session = await self.get_player_session(character_id)
if session:
await self.update_player_session_field(character_id, "websocket_connected", "false")
await self.update_player_session_field(character_id, "disconnect_time", str(time.time()))
async def mark_player_connected(self, character_id: int):
"""Mark player as connected."""
await self.update_player_session_field(character_id, "websocket_connected", "true")
# Remove disconnect time
key = f"player:{character_id}:session"
await self.redis_client.hdel(key, "disconnect_time")
async def is_player_connected(self, character_id: int) -> bool:
"""Check if player is currently connected via WebSocket."""
session = await self.get_player_session(character_id)
if not session:
return False
return session.get("websocket_connected") == "true"
async def get_disconnect_duration(self, character_id: int) -> Optional[float]:
"""Get how long player has been disconnected (in seconds)."""
session = await self.get_player_session(character_id)
if not session or session.get("websocket_connected") == "true":
return None
disconnect_time = session.get("disconnect_time")
if not disconnect_time:
return None
return time.time() - float(disconnect_time)
async def cleanup_disconnected_player(self, character_id: int):
"""Remove disconnected player from location registry (after timeout)."""
session = await self.get_player_session(character_id)
if session:
location_id = session.get("location_id")
if location_id:
await self.remove_player_from_location(character_id, location_id)
await self.delete_player_session(character_id)
# ==================== UTILITY ====================
async def ping(self) -> bool:
"""Test Redis connection."""
try:
await self.redis_client.ping()
return True
except Exception:
return False
async def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics."""
info = await self.redis_client.info("stats")
return {
"total_commands_processed": info.get("total_commands_processed", 0),
"instantaneous_ops_per_sec": info.get("instantaneous_ops_per_sec", 0),
"keyspace_hits": info.get("keyspace_hits", 0),
"keyspace_misses": info.get("keyspace_misses", 0),
"connected_clients": info.get("connected_clients", 0),
}
# ==================== CONNECTED PLAYERS COUNTER ====================
async def increment_connected_player(self, player_id: int):
"""Increment connection count for a player."""
key = "connected_players_counts"
await self.redis_client.hincrby(key, str(player_id), 1)
async def decrement_connected_player(self, player_id: int):
"""Decrement connection count for a player. Remove if 0."""
key = "connected_players_counts"
count = await self.redis_client.hincrby(key, str(player_id), -1)
if count <= 0:
await self.redis_client.hdel(key, str(player_id))
async def get_connected_player_count(self) -> int:
"""Get total number of unique connected players."""
key = "connected_players_counts"
return await self.redis_client.hlen(key)
# Global instance
redis_manager = RedisManager()

View File

@@ -3,10 +3,15 @@ fastapi==0.104.1
uvicorn[standard]==0.24.0
gunicorn==21.2.0
python-multipart==0.0.6
websockets==12.0
# Database
sqlalchemy==2.0.23
psycopg[binary]==3.1.13
asyncpg==0.29.0 # For migration scripts
# Redis
redis[hiredis]==5.0.1
# Authentication
pyjwt==2.8.0

0
api/routers/__init__.py Normal file
View File

370
api/routers/admin.py Normal file
View File

@@ -0,0 +1,370 @@
"""
Internal/Admin API router.
Endpoints for internal services (bot, admin tools, etc.)
Requires API_INTERNAL_KEY for authentication.
"""
from fastapi import APIRouter, HTTPException, Depends
from typing import Dict, Any
import json
from ..core.security import verify_internal_key
from .. import database as db
from ..items import ItemsManager
# These will be injected by main.py
LOCATIONS = None
ITEMS_MANAGER = None
WORLD = None
IMAGES_DIR = None
def init_router_dependencies(locations, items_manager, world, images_dir):
"""Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
IMAGES_DIR = images_dir
router = APIRouter(prefix="/api/internal", tags=["internal"], dependencies=[Depends(verify_internal_key)])
# Player endpoints
@router.get("/player/by_id/{player_id}")
async def get_player_by_id(player_id: int):
"""Get player data by ID"""
player = await db.get_player_by_id(player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
return player
@router.patch("/player/{player_id}")
async def update_player(player_id: int, data: dict):
"""Update player"""
await db.update_player(player_id, **data)
return {"success": True}
@router.get("/player/{player_id}/inventory")
async def get_inventory(player_id: int):
"""Get player inventory"""
inventory = await db.get_inventory(player_id)
return inventory
@router.get("/player/{player_id}/status-effects")
async def get_player_status_effects(player_id: int):
"""Get player's active status effects"""
effects = await db.get_active_status_effects(player_id)
return effects
# Combat endpoints
@router.get("/player/{player_id}/combat")
async def get_player_combat(player_id: int):
"""Get player's active combat"""
combat = await db.get_active_combat(player_id)
return combat
@router.post("/combat/create")
async def create_combat(data: dict):
"""Create combat"""
return await db.create_combat(**data)
@router.patch("/combat/{player_id}")
async def update_combat(player_id: int, data: dict):
"""Update combat"""
await db.update_combat(player_id, **data)
return {"success": True}
@router.delete("/combat/{player_id}")
async def end_combat(player_id: int):
"""End combat"""
await db.end_combat(player_id)
return {"success": True}
# Game action endpoints
@router.post("/player/{player_id}/move")
async def move_player(player_id: int, data: dict):
"""Move player"""
from .. import game_logic
success, message, new_location_id, stamina_cost, distance = await game_logic.move_player(
player_id,
data['direction'],
LOCATIONS
)
return {"success": success, "message": message, "new_location_id": new_location_id}
@router.get("/player/{player_id}/inspect")
async def inspect_player(player_id: int):
"""Inspect area for player"""
player = await db.get_player_by_id(player_id)
location = LOCATIONS.get(player['location_id'])
from .. import game_logic
message = await game_logic.inspect_area(player_id, location, {})
return {"message": message}
@router.post("/player/{player_id}/interact")
async def interact_player(player_id: int, data: dict):
"""Interact for player"""
player = await db.get_player_by_id(player_id)
location = LOCATIONS.get(player['location_id'])
from .. import game_logic
result = await game_logic.interact_with_object(
player_id,
data['interactable_id'],
data['action_id'],
location,
ITEMS_MANAGER
)
return result
@router.post("/player/{player_id}/use_item")
async def use_item(player_id: int, data: dict):
"""Use item"""
from .. import game_logic
result = await game_logic.use_item(player_id, data['item_id'], ITEMS_MANAGER)
return result
@router.post("/player/{player_id}/pickup")
async def pickup_item(player_id: int, data: dict):
"""Pickup item"""
player = await db.get_player_by_id(player_id)
from .. import game_logic
result = await game_logic.pickup_item(
player_id,
data['item_id'],
player['location_id'],
data.get('quantity'),
ITEMS_MANAGER
)
return result
@router.post("/player/{player_id}/drop_item")
async def drop_item(player_id: int, data: dict):
"""Drop item"""
player = await db.get_player_by_id(player_id)
await db.drop_item(player_id, data['item_id'], data['quantity'], player['location_id'])
return {"success": True}
# Equipment endpoints
@router.post("/player/{player_id}/equip")
async def equip_item(player_id: int, data: dict):
"""Equip item"""
inv_item = await db.get_inventory_item_by_id(data['inventory_id'])
if not inv_item:
raise HTTPException(status_code=404, detail="Item not found")
item_def = ITEMS_MANAGER.get_item(inv_item['item_id'])
if not item_def or not item_def.equippable:
raise HTTPException(status_code=400, detail="Item not equippable")
# Unequip current item in slot if any
current = await db.get_equipped_item_in_slot(player_id, item_def.slot)
if current:
await db.unequip_item(player_id, item_def.slot)
await db.update_inventory_item(current['item_id'], is_equipped=False)
# Equip new item
await db.equip_item(player_id, item_def.slot, data['inventory_id'])
await db.update_inventory_item(data['inventory_id'], is_equipped=True)
return {"success": True, "slot": item_def.slot}
@router.post("/player/{player_id}/unequip")
async def unequip_item(player_id: int, data: dict):
"""Unequip item"""
equipped = await db.get_equipped_item_in_slot(player_id, data['slot'])
if not equipped:
raise HTTPException(status_code=400, detail="No item in slot")
await db.unequip_item(player_id, data['slot'])
await db.update_inventory_item(equipped['item_id'], is_equipped=False)
return {"success": True}
# Dropped items endpoints
@router.post("/dropped-items")
async def create_dropped_item(data: dict):
"""Create dropped item"""
await db.drop_item(None, data['item_id'], data['quantity'], data['location_id'])
return {"success": True}
@router.get("/dropped-items/{dropped_item_id}")
async def get_dropped_item(dropped_item_id: int):
"""Get dropped item"""
item = await db.get_dropped_item(dropped_item_id)
return item
@router.get("/location/{location_id}/dropped-items")
async def get_location_dropped_items(location_id: str):
"""Get location's dropped items"""
items = await db.get_dropped_items(location_id)
return items
@router.patch("/dropped-items/{dropped_item_id}")
async def update_dropped_item(dropped_item_id: int, data: dict):
"""Update dropped item"""
return {"success": True}
@router.delete("/dropped-items/{dropped_item_id}")
async def delete_dropped_item(dropped_item_id: int):
"""Delete dropped item"""
await db.delete_dropped_item(dropped_item_id)
return {"success": True}
# Corpse endpoints - Player
@router.post("/corpses/player")
async def create_player_corpse(data: dict):
"""Create player corpse"""
corpse_id = await db.create_player_corpse(**data)
return {"id": corpse_id}
@router.get("/corpses/player/{corpse_id}")
async def get_player_corpse(corpse_id: int):
"""Get player corpse"""
corpse = await db.get_player_corpse(corpse_id)
return corpse
@router.patch("/corpses/player/{corpse_id}")
async def update_player_corpse(corpse_id: int, data: dict):
"""Update player corpse"""
await db.update_player_corpse(corpse_id, **data)
return {"success": True}
@router.delete("/corpses/player/{corpse_id}")
async def delete_player_corpse(corpse_id: int):
"""Delete player corpse"""
await db.delete_player_corpse(corpse_id)
return {"success": True}
@router.get("/location/{location_id}/corpses/player")
async def get_player_corpses_in_location(location_id: str):
"""Get player corpses in location"""
corpses = await db.get_player_corpses_in_location(location_id)
return corpses
# Corpse endpoints - NPC
@router.post("/corpses/npc")
async def create_npc_corpse(data: dict):
"""Create NPC corpse"""
corpse_id = await db.create_npc_corpse(
npc_id=data['npc_id'],
location_id=data['location_id'],
loot=json.dumps(data['loot'])
)
return {"id": corpse_id}
@router.get("/corpses/npc/{corpse_id}")
async def get_npc_corpse(corpse_id: int):
"""Get NPC corpse"""
corpse = await db.get_npc_corpse(corpse_id)
return corpse
@router.patch("/corpses/npc/{corpse_id}")
async def update_npc_corpse(corpse_id: int, data: dict):
"""Update NPC corpse"""
await db.update_npc_corpse(corpse_id, **data)
return {"success": True}
@router.delete("/corpses/npc/{corpse_id}")
async def delete_npc_corpse(corpse_id: int):
"""Delete NPC corpse"""
await db.delete_npc_corpse(corpse_id)
return {"success": True}
@router.get("/location/{location_id}/corpses/npc")
async def get_npc_corpses_in_location(location_id: str):
"""Get NPC corpses in location"""
corpses = await db.get_npc_corpses_in_location(location_id)
return corpses
# Wandering enemies endpoints
@router.post("/wandering-enemies")
async def create_wandering_enemy(data: dict):
"""Create wandering enemy"""
enemy_id = await db.create_wandering_enemy(**data)
return {"id": enemy_id}
@router.get("/location/{location_id}/wandering-enemies")
async def get_wandering_enemies(location_id: str):
"""Get wandering enemies in location"""
enemies = await db.get_wandering_enemies_in_location(location_id)
return enemies
@router.delete("/wandering-enemies/{enemy_id}")
async def delete_wandering_enemy(enemy_id: int):
"""Delete wandering enemy"""
await db.delete_wandering_enemy(enemy_id)
return {"success": True}
# Inventory item endpoint
@router.get("/inventory/item/{item_db_id}")
async def get_inventory_item(item_db_id: int):
"""Get inventory item"""
item = await db.get_inventory_item_by_id(item_db_id)
return item
# Cooldown endpoints
@router.get("/cooldown/{cooldown_key}")
async def get_cooldown(cooldown_key: str):
"""Get cooldown"""
parts = cooldown_key.split(':')
if len(parts) >= 3:
expiry = await db.get_interactable_cooldown(parts[1], parts[2])
return {"expiry": expiry}
return {"expiry": None}
@router.post("/cooldown/{cooldown_key}")
async def set_cooldown(cooldown_key: str, data: dict):
"""Set cooldown"""
parts = cooldown_key.split(':')
if len(parts) >= 3:
await db.set_interactable_cooldown(parts[1], parts[2], data['duration'])
return {"success": True}
# Image cache endpoints
@router.get("/image-cache/{image_path:path}")
async def get_image_cache(image_path: str):
"""Check if image exists"""
full_path = IMAGES_DIR / image_path
return {"exists": full_path.exists()}
@router.post("/image-cache")
async def create_image_cache(data: dict):
"""Cache image"""
return {"success": True}

384
api/routers/auth.py Normal file
View File

@@ -0,0 +1,384 @@
"""
Authentication router.
Handles user registration, login, and profile retrieval.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from typing import Dict, Any
from ..core.security import create_access_token, hash_password, verify_password, get_current_user
from ..services.models import UserRegister, UserLogin
from .. import database as db
router = APIRouter(prefix="/api/auth", tags=["authentication"])
@router.post("/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 = hash_password(user.password)
# 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
}
@router.post("/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 verify_password(user.password, account['password_hash']):
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
}
@router.get("/me")
async def get_me(current_user: Dict[str, Any] = 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"]
}
@router.get("/account")
async def get_account(current_user: Dict[str, Any] = Depends(get_current_user)):
"""Get current account details including characters"""
# Get account from current user's account_id
account_id = current_user.get("account_id")
if not account_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No account associated with this user"
)
account = await db.get_account_by_id(account_id)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Account not found"
)
# Get characters for this account
characters = await db.get_characters_by_account_id(account_id)
return {
"account": {
"id": account["id"],
"email": account["email"],
"account_type": account["account_type"],
"is_premium": account.get("premium_expires_at") is not None and account.get("premium_expires_at") > 0,
"premium_expires_at": account.get("premium_expires_at"),
"created_at": account.get("created_at"),
"last_login_at": account.get("last_login_at"),
},
"characters": [
{
"id": char["id"],
"name": char["name"],
"level": char["level"],
"xp": char["xp"],
"hp": char["hp"],
"max_hp": char["max_hp"],
"location_id": char["location_id"],
"avatar_data": char.get("avatar_data"),
"last_played_at": char.get("last_played_at"),
}
for char in characters
]
}
@router.post("/change-email")
async def change_email(
request: "ChangeEmailRequest",
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Change account email address"""
from ..services.models import ChangeEmailRequest
# Get account
account_id = current_user.get("account_id")
if not account_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No account associated with this user"
)
account = await db.get_account_by_id(account_id)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Account not found"
)
# Verify current password
if not account.get('password_hash'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This account does not have a password set"
)
if not verify_password(request.current_password, account['password_hash']):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect"
)
# Validate new email format
import re
email_regex = r'^[^\s@]+@[^\s@]+\.[^\s@]+$'
if not re.match(email_regex, request.new_email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid email format"
)
# Update email
try:
await db.update_account_email(account_id, request.new_email)
return {"message": "Email updated successfully", "new_email": request.new_email}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/change-password")
async def change_password(
request: "ChangePasswordRequest",
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Change account password"""
from ..services.models import ChangePasswordRequest
# Get account
account_id = current_user.get("account_id")
if not account_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No account associated with this user"
)
account = await db.get_account_by_id(account_id)
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Account not found"
)
# Verify current password
if not account.get('password_hash'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This account does not have a password set"
)
if not verify_password(request.current_password, account['password_hash']):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect"
)
# Validate new password
if len(request.new_password) < 6:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password must be at least 6 characters"
)
# Hash and update password
new_password_hash = hash_password(request.new_password)
await db.update_account_password(account_id, new_password_hash)
return {"message": "Password updated successfully"}
@router.post("/steam-login")
async def steam_login(steam_data: Dict[str, Any]):
"""
Login or register with Steam account.
Creates account if it doesn't exist.
"""
steam_id = steam_data.get("steam_id")
steam_name = steam_data.get("steam_name", "Steam User")
if not steam_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Steam ID is required"
)
# Try to find existing account by steam_id
account = await db.get_account_by_steam_id(steam_id)
if not account:
# Create new Steam account
# Use steam_id as email (unique identifier)
email = f"steam_{steam_id}@steamuser.local"
account = await db.create_account(
email=email,
password_hash=None, # Steam accounts don't have passwords
account_type="steam",
steam_id=steam_id
)
if not account:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create Steam account"
)
# 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"],
"steam_id": steam_id,
"steam_name": steam_name,
"premium_expires_at": account.get("premium_expires_at"),
"created_at": account.get("created_at"),
"last_login_at": account.get("last_login_at")
},
"characters": [
{
"id": char["id"],
"name": char["name"],
"level": char["level"],
"xp": char["xp"],
"hp": char["hp"],
"max_hp": char["max_hp"],
"location_id": char["location_id"],
"avatar_data": char.get("avatar_data"),
"last_played_at": char.get("last_played_at")
}
for char in characters
],
"needs_character_creation": len(characters) == 0
}

238
api/routers/characters.py Normal file
View File

@@ -0,0 +1,238 @@
"""
Character management router.
Handles character creation, selection, and deletion.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import HTTPAuthorizationCredentials
from ..core.security import decode_token, create_access_token, security
from ..services.models import CharacterCreate, CharacterSelect
from .. import database as db
router = APIRouter(prefix="/api/characters", tags=["characters"])
@router.get("")
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
]
}
@router.post("")
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"),
}
}
@router.post("/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"),
}
}
@router.delete("/{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"
}

1060
api/routers/combat.py Normal file

File diff suppressed because it is too large Load Diff

561
api/routers/crafting.py Normal file
View File

@@ -0,0 +1,561 @@
"""
Crafting router.
Auto-generated from main.py migration.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import HTTPAuthorizationCredentials
from typing import Optional, Dict, Any
from datetime import datetime
import random
import json
import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
from ..core.websockets import manager
from .equipment import consume_tool_durability
logger = logging.getLogger(__name__)
# These will be injected by main.py
LOCATIONS = None
ITEMS_MANAGER = None
WORLD = None
def init_router_dependencies(locations, items_manager, world):
"""Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
router = APIRouter(tags=["crafting"])
# Endpoints
@router.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:
# Check if player has this tool (find one with highest durability)
has_tool = False
tool_durability = 0
best_tool_unique = None
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:
if best_tool_unique is None or unique.get('durability', 0) > best_tool_unique.get('durability', 0):
best_tool_unique = unique
has_tool = True
tool_durability = unique.get('durability', 0)
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,
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'craft'),
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'craft'),
'base_stats': {k: int(v) if isinstance(v, (int, float)) else v for k, v in getattr(item_def, 'stats', {}).items()}
})
# 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
@router.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']
})
# Calculate stamina cost
stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'craft')
# Check stamina
if player['stamina'] < stamina_cost:
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {stamina_cost}, have {player['stamina']}")
# Deduct stamina
new_stamina = max(0, player['stamina'] - stamina_cost)
await db.update_player_stamina(current_user['id'], new_stamina)
# 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,
'stamina_cost': stamina_cost,
'new_stamina': new_stamina
}
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
@router.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 = []
# Calculate stamina cost
stamina_cost = calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
# Check stamina
if player['stamina'] < stamina_cost:
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {stamina_cost}, have {player['stamina']}")
# Deduct stamina
new_stamina = max(0, player['stamina'] - stamina_cost)
await db.update_player_stamina(current_user['id'], new_stamina)
# 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
# Re-fetch inventory to get updated capacity after removing the item
inventory = await db.get_inventory(current_user['id'])
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
# Calculate materials with loss chance and durability reduction
import random
loss_chance = getattr(item_def, 'uncraft_loss_chance', 0.3)
yield_info = {
'base_yield': uncraft_yield,
'loss_chance': loss_chance,
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft')
}
materials_yielded = []
materials_lost = []
materials_dropped = []
for material in uncraft_yield:
# Apply durability reduction first
base_quantity = material['quantity']
# Calculate adjusted quantity based on durability
# Use round() to ensure minimum yield of 1 for high durability items (e.g. 90% of 1 = 0.9 -> 1)
adjusted_quantity = int(round(base_quantity * durability_ratio))
mat_def = ITEMS_MANAGER.items.get(material['item_id'])
# If durability is too low (< 10%), yield nothing for this material
if durability_ratio < 0.1 or adjusted_quantity <= 0:
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
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:
# Check if it fits in inventory
mat_weight = getattr(mat_def, 'weight', 0) * adjusted_quantity
mat_volume = getattr(mat_def, 'volume', 0) * adjusted_quantity
if current_weight + mat_weight <= max_weight and current_volume + mat_volume <= max_volume:
# Fits in inventory
await db.add_item_to_inventory(
player_id=current_user['id'],
item_id=material['item_id'],
quantity=adjusted_quantity
)
# Update current capacity tracking
current_weight += mat_weight
current_volume += mat_volume
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
})
else:
# Inventory full - drop to ground
await db.drop_item_to_world(
item_id=material['item_id'],
quantity=adjusted_quantity,
location_id=player['location_id']
)
materials_dropped.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)."
if materials_dropped:
message += f" Inventory full! Dropped {len(materials_dropped)} item(s) to the ground."
return {
'success': True,
'message': message,
'item_name': item_def.name,
'materials_yielded': materials_yielded,
'materials_lost': materials_lost,
'materials_dropped': materials_dropped,
'tools_consumed': tools_consumed,
'loss_chance': loss_chance,
'durability_ratio': round(durability_ratio, 2),
'stamina_cost': stamina_cost,
'new_stamina': new_stamina
}
except Exception as e:
print(f"Error uncrafting item: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))

783
api/routers/equipment.py Normal file
View File

@@ -0,0 +1,783 @@
"""
Equipment router.
Auto-generated from main.py migration.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import HTTPAuthorizationCredentials
from typing import Optional, Dict, Any
from datetime import datetime
import random
import json
import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity, calculate_crafting_stamina_cost
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
from ..core.websockets import manager
logger = logging.getLogger(__name__)
# These will be injected by main.py
LOCATIONS = None
ITEMS_MANAGER = None
WORLD = None
def init_router_dependencies(locations, items_manager, world):
"""Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
router = APIRouter(tags=["equipment"])
# Endpoints
@router.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
# Save base stats to unique_stats
base_stats = {k: int(v) if isinstance(v, (int, float)) else v for k, v in item_def.stats.items()} if item_def.stats else {}
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=base_stats
)
# 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
}
@router.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:
# Use unique_stats if this is a unique item, otherwise fall back to default stats
if inv.get('unique_item_id'):
unique_item = await db.get_unique_item(inv['unique_item_id'])
if unique_item and unique_item.get('unique_stats'):
max_volume += unique_item['unique_stats'].get('volume_capacity', 0)
elif item.stats:
max_volume += item.stats.get('volume_capacity', 0)
# If unequipping backpack, check if items will fit
if unequip_req.slot == 'backpack':
# Get the backpack's volume capacity from unique_stats if available
backpack_volume = 0
if inv_item.get('unique_item_id'):
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
if unique_item and unique_item.get('unique_stats'):
backpack_volume = unique_item['unique_stats'].get('volume_capacity', 0)
elif item_def.stats:
backpack_volume = item_def.stats.get('volume_capacity', 0)
if backpack_volume > 0 and 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
}
@router.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}
@router.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)
# Calculate stamina cost
stamina_cost = calculate_crafting_stamina_cost(unique_item.get('tier', 1), 'repair')
# Check stamina
if player['stamina'] < stamina_cost:
raise HTTPException(status_code=400, detail=f"Not enough stamina. Need {stamina_cost}, have {player['stamina']}")
# Deduct stamina
new_stamina = max(0, player['stamina'] - stamina_cost)
await db.update_player_stamina(player_id, new_stamina)
# 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,
"stamina_cost": stamina_cost,
"new_stamina": new_stamina
}
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', 'torso', '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
@router.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 (find one with highest durability)
tool_found = False
tool_durability = 0
best_tool_unique = None
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:
if best_tool_unique is None or unique.get('durability', 0) > best_tool_unique.get('durability', 0):
best_tool_unique = unique
tool_found = True
tool_durability = unique.get('durability', 0)
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,
'unique_item_data': {k: int(v) if isinstance(v, (int, float)) and k != 'durability_percent' else v for k, v in unique_item.items()},
'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' if inv_item.get('is_equipped') else 'inventory',
'stamina_cost': calculate_crafting_stamina_cost(unique_item.get('tier', 1), 'repair'),
'type': getattr(item_def, 'type', 'misc')
})
# 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))
@router.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']
})
# Check tools availability for uncrafting
uncraft_tools = getattr(item_def, 'uncraft_tools', [])
tools_info = []
has_tools = True
for tool_req in uncraft_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 (find one with highest durability)
tool_found = False
tool_durability = 0
best_tool_unique = None
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:
if best_tool_unique is None or unique.get('durability', 0) > best_tool_unique.get('durability', 0):
best_tool_unique = unique
tool_found = True
tool_durability = unique.get('durability', 0)
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_uncraft = has_tools
# Build item entry
item_entry = {
'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,
'image_path': getattr(item_def, 'image_path', None),
'tier': getattr(item_def, 'tier', 1),
'quantity': inv_item['quantity'],
'base_yield': yield_info,
'loss_chance': getattr(item_def, 'uncraft_loss_chance', 0.3),
'stamina_cost': calculate_crafting_stamina_cost(getattr(item_def, 'tier', 1), 'uncraft'),
'can_uncraft': can_uncraft,
'uncraft_tools': tools_info,
'location': 'equipped' if inv_item.get('is_equipped') else 'inventory',
'type': getattr(item_def, 'type', 'misc')
}
# Add unique item data if available
if unique_item_data:
item_entry['unique_item_data'] = unique_item_data
item_entry['unique_stats'] = unique_item_data.get('unique_stats', {})
item_entry['current_durability'] = unique_item_data.get('current_durability')
item_entry['max_durability'] = unique_item_data.get('max_durability')
item_entry['durability_percent'] = unique_item_data.get('durability_percent')
salvageable_items.append(item_entry)
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)

1391
api/routers/game_routes.py Normal file

File diff suppressed because it is too large Load Diff

504
api/routers/loot.py Normal file
View File

@@ -0,0 +1,504 @@
"""
Loot router.
Auto-generated from main.py migration.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import HTTPAuthorizationCredentials
from typing import Optional, Dict, Any
from datetime import datetime
import random
import json
import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
from ..core.websockets import manager
from .equipment import consume_tool_durability
logger = logging.getLogger(__name__)
# These will be injected by main.py
LOCATIONS = None
ITEMS_MANAGER = None
WORLD = None
def init_router_dependencies(locations, items_manager, world):
"""Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
router = APIRouter(tags=["loot"])
# Endpoints
@router.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")
@router.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
inventory = await db.get_inventory(player['id'])
current_weight, max_weight, current_volume, max_volume = await calculate_player_capacity(inventory, ITEMS_MANAGER)
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")
# Use inventory already fetched for capacity calculation
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")

109
api/routers/statistics.py Normal file
View File

@@ -0,0 +1,109 @@
"""
Statistics router.
Auto-generated from main.py migration.
"""
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import HTTPAuthorizationCredentials
from typing import Optional, Dict, Any
from datetime import datetime
import random
import json
import logging
from ..core.security import get_current_user, security, verify_internal_key
from ..services.models import *
from ..services.helpers import calculate_distance, calculate_stamina_cost, calculate_player_capacity
from .. import database as db
from ..items import ItemsManager
from .. import game_logic
from ..core.websockets import manager
logger = logging.getLogger(__name__)
# These will be injected by main.py
LOCATIONS = None
ITEMS_MANAGER = None
WORLD = None
def init_router_dependencies(locations, items_manager, world):
"""Initialize router with game data dependencies"""
global LOCATIONS, ITEMS_MANAGER, WORLD
LOCATIONS = locations
ITEMS_MANAGER = items_manager
WORLD = world
router = APIRouter(tags=["statistics"])
# Endpoints
@router.get("/api/statistics/online-players")
async def get_online_players():
"""Get the current number of connected players"""
from ..redis_manager import redis_manager
if not redis_manager:
return {"count": 0}
count = await redis_manager.get_connected_player_count()
return {"count": count}
@router.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}
@router.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
}
@router.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
}

0
api/services/__init__.py Normal file
View File

245
api/services/helpers.py Normal file
View File

@@ -0,0 +1,245 @@
"""
Helper utilities for game calculations and common operations.
Contains distance calculations, stamina costs, capacity calculations, etc.
"""
import math
from typing import Tuple, List, Dict, Any
from .. import database as db
from ..items import ItemsManager
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)
"""
coord_distance = math.sqrt((x2 - x1)**2 + (y2 - y1)**2)
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
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)
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
def calculate_crafting_stamina_cost(tier: int, action_type: str = 'craft') -> int:
"""
Calculate stamina cost for workbench actions.
Args:
tier: Item tier (1-5)
action_type: 'craft', 'repair', or 'uncraft'
Returns:
Stamina cost
"""
if action_type == 'craft':
# Crafting: max(5, tier * 3) -> T1=5, T5=15
return max(5, tier * 3)
elif action_type == 'repair':
# Repairing: max(3, tier * 2) -> T1=3, T5=10
return max(3, tier * 2)
elif action_type == 'uncraft':
# Salvaging: max(2, tier * 1) -> T1=2, T5=5
return max(2, tier * 1)
return 1
async def calculate_player_capacity(inventory: List[Dict[str, Any]], items_manager: ItemsManager) -> Tuple[float, float, float, float]:
"""
Calculate player's current and max weight/volume capacity.
Uses unique_stats for equipped items with unique_item_id.
Args:
inventory: List of inventory items (from db.get_inventory)
items_manager: ItemsManager instance
Returns: (current_weight, max_weight, current_volume, max_volume)
"""
current_weight = 0.0
current_volume = 0.0
max_weight = 10.0 # Base capacity
max_volume = 10.0 # Base capacity
# Collect all unique_item_ids for equipped items
equipped_unique_item_ids = [
inv_item['unique_item_id']
for inv_item in inventory
if inv_item.get('is_equipped') and inv_item.get('unique_item_id')
]
# Batch fetch all unique items in one query
unique_items_map = {}
if equipped_unique_item_ids:
unique_items_map = await db.get_unique_items_batch(equipped_unique_item_ids)
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']:
# Use unique_stats if this is a unique item, otherwise fall back to default stats
if inv_item.get('unique_item_id'):
unique_item = unique_items_map.get(inv_item['unique_item_id'])
if unique_item and unique_item.get('unique_stats'):
max_weight += unique_item['unique_stats'].get('weight_capacity', 0)
max_volume += unique_item['unique_stats'].get('volume_capacity', 0)
elif item_def.stats:
# Fallback to default stats if no unique_item_id
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
async def reduce_armor_durability(player_id: int, damage_taken: int, items_manager: ItemsManager) -> Tuple[int, List[Dict[str, Any]]]:
"""
Reduce durability of equipped armor pieces when taking damage.
Returns: (armor_damage_absorbed, broken_armor_pieces)
"""
equipment = await db.get_all_equipment(player_id)
armor_pieces = ['head', 'torso', '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
armor_absorbed = min(damage_taken // 2, total_armor)
# Calculate durability loss for each armor piece
base_reduction_rate = 0.1
broken_armor = []
for armor in equipped_armor:
proportion = armor['armor_value'] / total_armor if total_armor > 0 else 0
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)
# If armor is about to break, unequip it first
if new_durability <= 0:
await db.unequip_item(player_id, armor['slot'])
# We don't need to manually update inventory is_equipped or remove_from_inventory
# because decrease_unique_item_durability will delete the unique item,
# which cascades to the inventory row.
broken_armor.append({
'name': armor['item_def'].name,
'emoji': getattr(armor['item_def'], 'emoji', '🛡️')
})
# Decrease durability (handles deletion if <= 0)
await db.decrease_unique_item_durability(armor['unique_item_id'], durability_loss)
return armor_absorbed, broken_armor
async def consume_tool_durability(user_id: int, tools: list, inventory: list, items_manager: ItemsManager) -> Tuple[bool, str, list]:
"""
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:
item_def = items_manager.get_item(inv_item['item_id'])
if item_def and item_def.tool_type and inv_item.get('unique_item_id'):
unique_item = await db.get_unique_item(inv_item['unique_item_id'])
if unique_item and unique_item.get('durability', 0) > 0:
tool_type = item_def.tool_type
if tool_type not in tools_map:
tools_map[tool_type] = []
tools_map[tool_type].append({
'inv_item_id': inv_item['id'],
'unique_item_id': inv_item['unique_item_id'],
'item_id': inv_item['item_id'],
'durability': unique_item['durability'],
'name': item_def.name,
'emoji': getattr(item_def, 'emoji', '🔧')
})
# Check and consume tools
for tool_req in tools:
tool_type = tool_req['type']
durability_cost = tool_req.get('durability_cost', 1)
if tool_type not in tools_map or not tools_map[tool_type]:
return False, f"Missing required tool: {tool_type}", []
# Use first available tool of this type
tool = tools_map[tool_type][0]
new_durability = tool['durability'] - durability_cost
if new_durability <= 0:
# Tool breaks - unequip first
await db.unequip_item(user_id, 'weapon') # Assuming tools are equipped as weapons
consumed_tools.append(f"{tool['emoji']} {tool['name']} (broke)")
tools_map[tool_type].pop(0)
else:
consumed_tools.append(f"{tool['emoji']} {tool['name']} (-{durability_cost} durability)")
# Decrease durability (handles deletion if <= 0)
await db.decrease_unique_item_durability(tool['unique_item_id'], durability_cost)
return True, "", consumed_tools

131
api/services/models.py Normal file
View File

@@ -0,0 +1,131 @@
"""
Pydantic models for request/response validation.
All API request and response models are defined here.
"""
from pydantic import BaseModel
from typing import Optional
# ============================================================================
# Authentication Models
# ============================================================================
class UserRegister(BaseModel):
email: str
password: str
class UserLogin(BaseModel):
email: str
password: str
class ChangeEmailRequest(BaseModel):
current_password: str
new_email: str
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
# ============================================================================
# Character Models
# ============================================================================
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
# ============================================================================
# Game Action Models
# ============================================================================
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 # dropped_item database ID
quantity: int = 1
# ============================================================================
# Combat Models
# ============================================================================
class InitiateCombatRequest(BaseModel):
enemy_id: int # wandering_enemies.id
class CombatActionRequest(BaseModel):
action: str # 'attack', 'defend', 'flee'
class PvPCombatInitiateRequest(BaseModel):
target_player_id: int
class PvPAcknowledgeRequest(BaseModel):
pass # No body needed
class PvPCombatActionRequest(BaseModel):
action: str # 'attack', 'defend', 'flee'
# ============================================================================
# Equipment Models
# ============================================================================
class EquipItemRequest(BaseModel):
inventory_id: int
class UnequipItemRequest(BaseModel):
slot: str
class RepairItemRequest(BaseModel):
inventory_id: int
# ============================================================================
# Crafting Models
# ============================================================================
class CraftItemRequest(BaseModel):
item_id: str
class UncraftItemRequest(BaseModel):
inventory_id: int
# ============================================================================
# Corpse/Loot Models
# ============================================================================
class LootCorpseRequest(BaseModel):
corpse_id: str # Format: "npc_{id}" or "player_{id}"
item_index: Optional[int] = None # Specific item index to loot, or None for all

View File

@@ -1,20 +1,14 @@
#!/bin/bash
# Startup script for API with auto-scaling workers
# Detect number of CPU cores
# Auto-detect worker count based on CPU cores
# Formula: (CPU_cores / 2) + 1, min 2, max 8
CPU_CORES=$(nproc)
WORKERS=$(( ($CPU_CORES / 2) + 1 ))
WORKERS=$(( WORKERS < 2 ? 2 : WORKERS ))
WORKERS=$(( WORKERS > 8 ? 8 : WORKERS ))
# Calculate optimal workers: (2 x CPU cores) + 1
# But cap at 8 workers to avoid over-saturation
WORKERS=$((2 * CPU_CORES + 1))
if [ $WORKERS -gt 8 ]; then
WORKERS=8
fi
# Use environment variable if set, otherwise use calculated value
WORKERS=${API_WORKERS:-$WORKERS}
echo "Starting API with $WORKERS workers (detected $CPU_CORES CPU cores)"
echo "Starting API with $WORKERS workers (auto-detected from $CPU_CORES CPU cores)"
exec gunicorn api.main:app \
--workers $WORKERS \