import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pathlib import Path
import time
from server.logger import logger
from server.websocket_manager import WebSocketManager
from server.game_state import GameState
from server.economy import EconomyEngine
from server.database import Database
# Global instances - can be overridden for testing
game_state = None
ws_manager = None
economy_engine = None
database = None
def initialize_components(test_db_path=None):
"""Initialize server components with optional test database"""
global game_state, ws_manager, economy_engine, database
game_state = GameState()
ws_manager = WebSocketManager(game_state)
economy_engine = EconomyEngine(game_state)
database = Database(test_db_path) if test_db_path else Database()
return game_state, ws_manager, economy_engine, database
# --- HYBRID MODEL RE-IMPLEMENTED ---
last_economy_tick_time = time.time()
TICK_INTERVAL = 10 # seconds
async def trigger_economy_update_and_save():
"""Triggers an economy tick, broadcasts updates concurrently, saves, and resets the timer."""
global last_economy_tick_time
logger.debug("Triggering full economy update and save cycle...")
start_time = time.perf_counter()
economy_engine.tick()
update_tasks = []
for player_id, player in game_state.players.items():
if player_id in ws_manager.active_connections:
task = ws_manager.send_to_player(player_id, {
"type": "player_stats_update",
"player": player.to_dict()
})
update_tasks.append(task)
if update_tasks:
await asyncio.gather(*update_tasks)
database.save_game_state(game_state)
# Reset the global tick timer after any update
last_economy_tick_time = time.time()
duration = time.perf_counter() - start_time
logger.info(f"Full economy update cycle completed in {duration:.4f} seconds.")
async def economy_loop():
"""Runs periodically to check if a passive economy update is needed."""
global last_economy_tick_time
while True:
# Check if 10 seconds have passed since the last tick (from any source)
if time.time() - last_economy_tick_time > TICK_INTERVAL:
logger.info("Triggering timed economy update for idle players.")
await trigger_economy_update_and_save()
# Check frequently for responsiveness
await asyncio.sleep(1)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events."""
global game_state, ws_manager, economy_engine, database
# Initialize components if not already done (for testing)
if not all([game_state, ws_manager, economy_engine, database]):
initialize_components()
logger.info("Server starting up...")
database.init_db()
game_state.load_state(database.load_game_state())
# Start the hybrid economy loop
task = asyncio.create_task(economy_loop())
logger.info("Hybrid economy loop started.")
yield
logger.info("Server shutting down...")
task.cancel()
database.save_game_state(game_state)
logger.info("Final game state saved.")
app = FastAPI(lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
async def root():
return FileResponse("static/index.html")
@app.websocket("/ws/{nickname}")
async def websocket_endpoint(websocket: WebSocket, nickname: str):
await ws_manager.connect(websocket, nickname)
try:
player_id = await ws_manager.get_player_id(websocket)
if player_id:
player = game_state.get_or_create_player(nickname, player_id)
await websocket.send_json({
"type": "init",
"player": player.to_dict(),
"game_state": game_state.get_state()
})
while True:
data = await websocket.receive_json()
await handle_message(websocket, data)
except WebSocketDisconnect:
await ws_manager.disconnect(websocket)
except Exception as e:
player_id = await ws_manager.get_player_id(websocket)
logger.error(f"An error occurred in the websocket endpoint for player {player_id}: {e}", exc_info=True)
async def handle_message(websocket: WebSocket, data: dict):
"""Handle incoming WebSocket messages."""
msg_type = data.get("type")
player_id = await ws_manager.get_player_id(websocket)
if not player_id:
logger.warning("Received message from an unknown player, ignoring.")
return
logger.debug(f"Received '{msg_type}' from player {player_id}")
if msg_type == "cursor_move":
await ws_manager.broadcast({"type": "cursor_move", "player_id": player_id, "x": data["x"], "y": data["y"]}, exclude=websocket)
elif msg_type == "place_building":
result = game_state.place_building(player_id, data["building_type"], data["x"], data["y"])
if result["success"]:
await ws_manager.broadcast({"type": "building_placed", "building": result["building"]})
# --- CHANGE: Trigger an INSTANT economy update on financial action ---
logger.info(f"Player {player_id} action triggered immediate economy update.")
await trigger_economy_update_and_save()
else:
await websocket.send_json({"type": "error", "message": result["error"]})
elif msg_type == "remove_building":
result = game_state.remove_building(player_id, data["x"], data["y"])
if result["success"]:
await ws_manager.broadcast({"type": "building_removed", "x": data["x"], "y": data["y"]})
# --- CHANGE: Trigger an INSTANT economy update on financial action ---
logger.info(f"Player {player_id} action triggered immediate economy update.")
await trigger_economy_update_and_save()
else:
await websocket.send_json({"type": "error", "message": result["error"]})
elif msg_type == "edit_building":
result = game_state.edit_building_name(player_id, data["x"], data["y"], data["name"])
if result["success"]:
await ws_manager.broadcast({"type": "building_updated", "x": data["x"], "y": data["y"], "name": data["name"]})
database.save_game_state(game_state)
elif msg_type == "chat":
nickname = await ws_manager.get_nickname(websocket)
await ws_manager.broadcast({"type": "chat", "nickname": nickname, "message": data["message"], "timestamp": data["timestamp"]})