import os import json import uuid import math import random import base64 import io import requests from fastapi import FastAPI, Request, Depends, HTTPException, status from fastapi.responses import JSONResponse, HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.security import HTTPBasic, HTTPBasicCredentials from dotenv import load_dotenv from PIL import Image, ImageDraw, ImageFont load_dotenv() # Paths BASE_DIR = os.path.dirname(__file__) TEMPLATES_DIR = os.path.join(BASE_DIR, "templates") STATIC_DIR = os.path.join(BASE_DIR, "static") ZONES_FILE = os.path.join(BASE_DIR, "zones.json") app = FastAPI() app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") templates = Jinja2Templates(directory=TEMPLATES_DIR) # In-memory state games = {} zones = {"easy": [], "medium": [], "hard": []} # --- Zone Persistence Functions --- def save_zones_to_file() -> None: try: with open(ZONES_FILE, 'w') as f: json.dump(zones, f, indent=4) except Exception as e: print(f"Error saving zones: {e}") def load_zones_from_file() -> None: global zones if os.path.exists(ZONES_FILE): try: with open(ZONES_FILE, 'r') as f: loaded_zones = json.load(f) if not (isinstance(loaded_zones, dict) and all(k in loaded_zones for k in ["easy", "medium", "hard"])): raise ValueError("Invalid zones format") migrated = False for difficulty in loaded_zones: for zone in loaded_zones[difficulty]: if 'id' not in zone: zone['id'] = uuid.uuid4().hex migrated = True zones = loaded_zones if migrated: save_zones_to_file() except Exception as e: print(f"Warning: '{ZONES_FILE}' is corrupted or invalid ({e}). Recreating with empty zones.") save_zones_to_file() else: save_zones_to_file() # Predefined fallback locations LOCATIONS = [ {'lat': 48.85824, 'lng': 2.2945}, # Eiffel Tower, Paris {'lat': 40.748440, 'lng': -73.985664}, # Empire State Building, New York {'lat': 35.689487, 'lng': 139.691711}, # Tokyo, Japan {'lat': -33.856784, 'lng': 151.215297} # Sydney Opera House, Australia ] def generate_game_id() -> str: return ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10)) def draw_compass_on_image(image_data_base64: str, heading: int) -> str: try: img = Image.open(io.BytesIO(base64.b64decode(image_data_base64))) img_with_compass = img.copy() draw = ImageDraw.Draw(img_with_compass) compass_size = 80 margin = 20 x = img.width - compass_size - margin y = margin center_x = x + compass_size // 2 center_y = y + compass_size // 2 draw.ellipse([x, y, x + compass_size, y + compass_size], fill=(240, 240, 240), outline=(249, 115, 22), width=3) font_size = 12 try: font = ImageFont.truetype("/System/Library/Fonts/Arial.ttf", font_size) except Exception: try: font = ImageFont.truetype("arial.ttf", font_size) except Exception: font = ImageFont.load_default() directions = [ ("N", center_x, y + 8, (220, 38, 38)), ("E", x + compass_size - 15, center_y, (249, 115, 22)), ("S", center_x, y + compass_size - 20, (249, 115, 22)), ("W", x + 8, center_y, (249, 115, 22)), ] for text, text_x, text_y, color in directions: bbox = draw.textbbox((0, 0), text, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] circle_radius = 10 draw.ellipse([text_x - circle_radius, text_y - circle_radius, text_x + circle_radius, text_y + circle_radius], fill=(255, 255, 255), outline=color, width=1) draw.text((text_x - text_width // 2, text_y - text_height // 2), text, font=font, fill=color) needle_length = compass_size // 2 - 15 needle_angle = math.radians(heading) end_x = center_x + needle_length * math.sin(needle_angle) end_y = center_y - needle_length * math.cos(needle_angle) draw.line([center_x, center_y, end_x, end_y], fill=(220, 38, 38), width=4) tip_radius = 3 draw.ellipse([end_x - tip_radius, end_y - tip_radius, end_x + tip_radius, end_y + tip_radius], fill=(220, 38, 38)) center_radius = 4 draw.ellipse([center_x - center_radius, center_y - center_radius, center_x + center_radius, center_y + center_radius], fill=(249, 115, 22)) label_y = y + compass_size + 5 label_text = f"{heading}°" bbox = draw.textbbox((0, 0), label_text, font=font) label_width = bbox[2] - bbox[0] draw.text((center_x - label_width // 2, label_y), label_text, font=font, fill=(249, 115, 22)) buffer = io.BytesIO() img_with_compass.save(buffer, format='JPEG', quality=85) return base64.b64encode(buffer.getvalue()).decode('utf-8') except Exception as e: print(f"Error drawing compass: {e}") return image_data_base64 # --- Auth --- security = HTTPBasic() def verify_basic_auth(credentials: HTTPBasicCredentials = Depends(security)) -> None: admin_user = os.getenv('ADMIN_USERNAME', 'admin') admin_pass = os.getenv('ADMIN_PASSWORD', 'password') if not (credentials.username == admin_user and credentials.password == admin_pass): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized", headers={"WWW-Authenticate": "Basic"}) @app.get("/", response_class=HTMLResponse) def index(request: Request): google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY') if not google_maps_api_key: return HTMLResponse("Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", status_code=500) base_path = request.scope.get('root_path', '') return templates.TemplateResponse("index.html", {"request": request, "google_maps_api_key": google_maps_api_key, "base_path": base_path}) @app.get("/admin", response_class=HTMLResponse) def admin(request: Request, _: None = Depends(verify_basic_auth)): google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY') if not google_maps_api_key: return HTMLResponse("Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", status_code=500) base_path = request.scope.get('root_path', '') return templates.TemplateResponse("admin.html", {"request": request, "google_maps_api_key": google_maps_api_key, "base_path": base_path}) @app.get("/api/zones") def get_zones(): return JSONResponse(zones) @app.post("/api/zones") def create_zone(payload: dict): difficulty = payload.get('difficulty') zone_data = payload.get('zone') if difficulty and zone_data and difficulty in zones: zone_data['id'] = uuid.uuid4().hex zones[difficulty].append(zone_data) save_zones_to_file() return {"message": "Zone saved successfully"} raise HTTPException(status_code=400, detail="Invalid data") @app.delete("/api/zones") def delete_zone(payload: dict): zone_id = payload.get('zone_id') if not zone_id: raise HTTPException(status_code=400, detail="Zone ID is required") for difficulty in zones: zones[difficulty] = [z for z in zones[difficulty] if z.get('id') != zone_id] save_zones_to_file() return {"message": "Zone deleted successfully"} def direction_to_degree(direction: str): directions = { 'N': 0, 'NORTH': 0, 'NE': 45, 'NORTHEAST': 45, 'E': 90, 'EAST': 90, 'SE': 135, 'SOUTHEAST': 135, 'S': 180, 'SOUTH': 180, 'SW': 225, 'SOUTHWEST': 225, 'W': 270, 'WEST': 270, 'NW': 315, 'NORTHWEST': 315 } return directions.get(direction.upper()) if isinstance(direction, str) else None def calculate_new_location(current_lat: float, current_lng: float, degree: float, distance_km: float = 0.1): lat_rad = math.radians(current_lat) lng_rad = math.radians(current_lng) bearing_rad = math.radians(degree) R = 6371.0 new_lat_rad = math.asin( math.sin(lat_rad) * math.cos(distance_km / R) + math.cos(lat_rad) * math.sin(distance_km / R) * math.cos(bearing_rad) ) new_lng_rad = lng_rad + math.atan2( math.sin(bearing_rad) * math.sin(distance_km / R) * math.cos(lat_rad), math.cos(distance_km / R) - math.sin(lat_rad) * math.sin(new_lat_rad) ) new_lat = math.degrees(new_lat_rad) new_lng = math.degrees(new_lng_rad) new_lng = ((new_lng + 180) % 360) - 180 return new_lat, new_lng @app.post("/start_game") def start_game(payload: dict): difficulty = payload.get('difficulty', 'easy') if payload else 'easy' player_name = payload.get('player_name', 'Anonymous Player') if payload else 'Anonymous Player' player_google_api_key = payload.get('google_api_key') if payload else None start_location = None if difficulty in zones and zones[difficulty]: selected_zone = random.choice(zones[difficulty]) if selected_zone.get('type') == 'rectangle': bounds = selected_zone['bounds'] north, south, east, west = bounds['north'], bounds['south'], bounds['east'], bounds['west'] if west > east: east += 360 rand_lng = random.uniform(west, east) if rand_lng > 180: rand_lng -= 360 rand_lat = random.uniform(south, north) start_location = {'lat': rand_lat, 'lng': rand_lng} if not start_location: start_location = random.choice(LOCATIONS) game_id = generate_game_id() games[game_id] = { 'start_location': start_location, 'current_location': start_location, 'guesses': [], 'moves': 0, 'actions': [], 'game_over': False, 'player_name': player_name, 'player_google_api_key': player_google_api_key, 'created_at': __import__('datetime').datetime.now().isoformat() } google_maps_api_key = player_google_api_key or os.getenv('GOOGLE_MAPS_API_KEY') streetview_image = None compass_heading = random.randint(0, 359) if google_maps_api_key: try: lat, lng = start_location['lat'], start_location['lng'] streetview_url = f"https://maps.googleapis.com/maps/api/streetview?size=640x400&location={lat},{lng}&heading={compass_heading}&pitch=0&fov=90&key={google_maps_api_key}" response = requests.get(streetview_url, timeout=20) if response.status_code == 200: base_image = base64.b64encode(response.content).decode('utf-8') streetview_image = draw_compass_on_image(base_image, compass_heading) except Exception as e: print(f"Error fetching Street View image: {e}") return { 'game_id': game_id, 'player_name': player_name, 'streetview_image': streetview_image, 'compass_heading': compass_heading } @app.get("/game/{game_id}/state") def get_game_state(game_id: str): game = games.get(game_id) if not game: raise HTTPException(status_code=404, detail='Game not found') return game @app.post("/game/{game_id}/move") def move(game_id: str, payload: dict): game = games.get(game_id) if not game: raise HTTPException(status_code=404, detail='Game not found') if game['game_over']: raise HTTPException(status_code=400, detail='Game is over') direction = payload.get('direction') if payload else None degree = payload.get('degree') if payload else None distance = payload.get('distance', 0.1) if payload else 0.1 if direction is None and degree is None: raise HTTPException(status_code=400, detail='Must provide either direction (N, NE, E, etc.) or degree (0-360)') if direction is not None: degree = direction_to_degree(direction) if degree is None: raise HTTPException(status_code=400, detail='Invalid direction. Use N, NE, E, SE, S, SW, W, NW or their full names') if not (0 <= degree <= 360): raise HTTPException(status_code=400, detail='Degree must be between 0 and 360') if not (0.01 <= distance <= 10): raise HTTPException(status_code=400, detail='Distance must be between 0.01 and 10 km') current_lat = game['current_location']['lat'] current_lng = game['current_location']['lng'] new_lat, new_lng = calculate_new_location(current_lat, current_lng, degree, distance) game['current_location'] = {'lat': new_lat, 'lng': new_lng} game['moves'] += 1 game['actions'].append({ 'type': 'move', 'location': {'lat': new_lat, 'lng': new_lng}, 'direction': direction, 'degree': degree, 'distance_km': distance }) google_maps_api_key = game.get('player_google_api_key') or os.getenv('GOOGLE_MAPS_API_KEY') streetview_image = None compass_heading = random.randint(0, 359) if google_maps_api_key: try: streetview_url = f"https://maps.googleapis.com/maps/api/streetview?size=640x400&location={new_lat},{new_lng}&heading={compass_heading}&pitch=0&fov=90&key={google_maps_api_key}" response = requests.get(streetview_url, timeout=20) if response.status_code == 200: base_image = base64.b64encode(response.content).decode('utf-8') streetview_image = draw_compass_on_image(base_image, compass_heading) except Exception as e: print(f"Error fetching Street View image: {e}") return { 'message': 'Move successful', 'streetview_image': streetview_image, 'compass_heading': compass_heading, 'moved_direction': direction or f"{degree}°", 'distance_moved_km': distance } @app.post("/game/{game_id}/guess") def guess(game_id: str, payload: dict): game = games.get(game_id) if not game: raise HTTPException(status_code=404, detail='Game not found') if game['game_over']: raise HTTPException(status_code=400, detail='Game is over') guess_lat = payload.get('lat') if payload else None guess_lng = payload.get('lng') if payload else None if guess_lat is None or guess_lng is None: raise HTTPException(status_code=400, detail='Missing lat/lng for guess') guess_location = {'lat': guess_lat, 'lng': guess_lng} game['guesses'].append(guess_location) from math import radians, cos, sin, asin, sqrt def haversine(lat1, lon1, lat2, lon2): lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2]) dlon = lon2 - lon1 dlat = lat2 - lat1 a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 c = 2 * asin(sqrt(a)) r = 6371 return c * r distance = haversine( game['start_location']['lat'], game['start_location']['lng'], guess_lat, guess_lng ) max_score = 5000 score = max(0, max_score - distance) game['actions'].append({ 'type': 'guess', 'location': guess_location, 'result': { 'distance_km': distance, 'score': score } }) game['game_over'] = True return { 'message': 'Guess received', 'guess_location': guess_location, 'actual_location': game['start_location'], 'distance_km': distance, 'score': score } # Load zones at startup load_zones_from_file()