262 lines
8.6 KiB
Python
262 lines
8.6 KiB
Python
"""
|
|
Echoes of the Ashes - Main FastAPI Application
|
|
Streamlined and modular architecture for easy maintenance
|
|
"""
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.security import HTTPAuthorizationCredentials
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
import logging
|
|
|
|
# Import core modules
|
|
from .core.config import CORS_ORIGINS, IMAGES_DIR, API_INTERNAL_KEY
|
|
from .core.websockets import manager
|
|
from .core.security import get_current_user, decode_token, security, verify_internal_key
|
|
|
|
# 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 all routers
|
|
from .routers import (
|
|
auth,
|
|
characters,
|
|
game_routes,
|
|
combat,
|
|
equipment,
|
|
crafting,
|
|
loot,
|
|
statistics,
|
|
admin
|
|
)
|
|
|
|
# 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 - Modular Architecture",
|
|
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}")
|
|
|
|
# Initialize routers with game data dependencies
|
|
game_routes.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
|
combat.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, redis_manager)
|
|
equipment.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
|
crafting.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
|
loot.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
|
statistics.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD)
|
|
admin.init_router_dependencies(LOCATIONS, ITEMS_MANAGER, WORLD, IMAGES_DIR)
|
|
|
|
# Include all routers
|
|
app.include_router(auth.router)
|
|
app.include_router(characters.router)
|
|
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(statistics.router)
|
|
app.include_router(admin.router)
|
|
|
|
print("✅ All routers registered")
|
|
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
"""Health check endpoint for load balancers"""
|
|
return {"status": "ok", "version": "2.0.0"}
|
|
|
|
|
|
@app.websocket("/ws/game/{token}")
|
|
async def websocket_endpoint(websocket: WebSocket, token: str):
|
|
"""
|
|
WebSocket endpoint for real-time game updates.
|
|
Clients connect with their JWT token in the path.
|
|
"""
|
|
|
|
try:
|
|
# Decode and validate token
|
|
payload = decode_token(token)
|
|
character_id = payload.get("character_id")
|
|
|
|
if not character_id:
|
|
await websocket.close(code=1008, reason="No character selected")
|
|
return
|
|
|
|
# Get character data
|
|
character = await db.get_player_by_id(character_id)
|
|
if not character:
|
|
await websocket.close(code=1008, reason="Character not found")
|
|
return
|
|
|
|
player_id = character['id']
|
|
username = character['name']
|
|
location_id = character['location_id']
|
|
|
|
# Connect WebSocket
|
|
await manager.connect(websocket, player_id, username)
|
|
|
|
# Register in Redis
|
|
if redis_manager:
|
|
await redis_manager.set_player_session(player_id, {
|
|
'username': username,
|
|
'location_id': location_id,
|
|
'hp': character.get('hp'),
|
|
'max_hp': character.get('max_hp'),
|
|
'stamina': character.get('stamina'),
|
|
'max_stamina': character.get('max_stamina'),
|
|
'level': character.get('level', 1),
|
|
'xp': character.get('xp', 0),
|
|
'websocket_connected': 'true'
|
|
})
|
|
|
|
# Add player to location registry
|
|
await redis_manager.add_player_to_location(player_id, location_id)
|
|
|
|
# Increment connected player count
|
|
await redis_manager.increment_connected_player(player_id)
|
|
|
|
# Broadcast new player count
|
|
count = await redis_manager.get_connected_player_count()
|
|
await redis_manager.publish_global_broadcast({
|
|
"type": "player_count_update",
|
|
"data": { "count": count }
|
|
})
|
|
|
|
logger.info(f"WebSocket connected: {username} (ID: {player_id})")
|
|
|
|
|
|
# Keep connection alive
|
|
while True:
|
|
try:
|
|
data = await websocket.receive_text()
|
|
# Handle ping/pong or other client messages
|
|
logger.debug(f"Received from {username}: {data}")
|
|
except WebSocketDisconnect:
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"WebSocket error for {username}: {e}")
|
|
break
|
|
|
|
except HTTPException as e:
|
|
await websocket.close(code=1008, reason=e.detail)
|
|
return
|
|
except Exception as e:
|
|
logger.error(f"WebSocket connection error: {e}")
|
|
await websocket.close(code=1011, reason="Internal error")
|
|
return
|
|
finally:
|
|
# Cleanup on disconnect
|
|
try:
|
|
await manager.disconnect(player_id, websocket)
|
|
|
|
if location_id and redis_manager:
|
|
await redis_manager.remove_player_from_location(player_id, location_id)
|
|
|
|
# Decrement connected player count
|
|
await redis_manager.decrement_connected_player(player_id)
|
|
|
|
# Broadcast new player count
|
|
count = await redis_manager.get_connected_player_count()
|
|
await redis_manager.publish_global_broadcast({
|
|
"type": "player_count_update",
|
|
"data": { "count": count }
|
|
})
|
|
|
|
logger.info(f"WebSocket disconnected: {username}")
|
|
except:
|
|
pass
|
|
|
|
|
|
print("\n" + "="*60)
|
|
print("✅ Echoes of the Ashes API - Ready")
|
|
print(f"📊 Total Routers: 9 (auth, characters, game, combat, equipment, crafting, loot, statistics, admin)")
|
|
print(f"🌍 Locations: {len(LOCATIONS)}")
|
|
print(f"📦 Items: {len(ITEMS_MANAGER.items)}")
|
|
print("="*60 + "\n")
|