Files
echoes-of-the-ash/api/main.py
2026-02-23 15:42:21 +01:00

334 lines
11 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,
statistics,
admin,
quests,
trade,
npcs
)
# 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, NPCS_DATA)
if tasks:
print(f"✅ Started {len(tasks)} background tasks in this worker")
else:
print("⏭️ Background tasks running in another worker")
# APPLY GLOBAL QUEST UNLOCKS
print("🔓 Applying global quest unlocks...")
try:
completed_quests = await db.get_completed_global_quests()
print(f" - Found {len(completed_quests)} completed global quests")
for quest_id in completed_quests:
# Unlock locations
for loc in LOCATIONS.values():
if loc.unlocked_by == quest_id:
loc.locked = False
print(f" - Unlocked location: {loc.id}")
# Unlock interactables
for inter in loc.interactables:
if inter.unlocked_by == quest_id:
inter.locked = False
print(f" - Unlocked interactable: {inter.id} in {loc.id}")
except Exception as e:
print(f"❌ Failed to apply global quest unlocks: {e}")
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
# Load Quests and NPCs Data at startup
QUESTS_DATA = {}
NPCS_DATA = {}
try:
print("🔄 Loading quests and NPCs...")
quests_path = Path("./gamedata/quests.json")
npcs_path = Path("./gamedata/static_npcs.json")
import json
if quests_path.exists():
with open(quests_path, "r") as f:
q_data = json.load(f)
QUESTS_DATA = q_data.get("quests", {})
print(f"✅ Loaded {len(QUESTS_DATA)} quests")
if npcs_path.exists():
with open(npcs_path, "r") as f:
n_data = json.load(f)
NPCS_DATA = n_data.get("static_npcs", {})
print(f"✅ Loaded {len(NPCS_DATA)} static NPCs")
# Load Enemies / Other NPCs
enemies_path = Path("./gamedata/npcs.json")
if enemies_path.exists():
with open(enemies_path, "r") as f:
e_data = json.load(f)
enemies = e_data.get("npcs", {})
# Merge into NPCS_DATA
NPCS_DATA.update(enemies)
print(f"✅ Loaded {len(enemies)} enemies/NPCs")
except Exception as e:
print(f"❌ Error loading game data: {e}")
# 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, QUESTS_DATA)
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)
quests.init_router_dependencies(ITEMS_MANAGER, QUESTS_DATA, NPCS_DATA, LOCATIONS)
trade.init_router_dependencies(ITEMS_MANAGER, NPCS_DATA)
npcs.init_router_dependencies()
# 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)
app.include_router(quests.router)
app.include_router(trade.router)
app.include_router(npcs.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_text = await websocket.receive_text()
try:
data_json = json.loads(data_text)
if data_json.get("type") == "ack":
logger.debug(f"ACK received from {username} for msg {data_json.get('reply_to')}")
else:
logger.debug(f"Received from {username}: {data_text}")
except:
logger.debug(f"Received from {username}: {data_text}")
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")